Module: Parse::AtlasSearch
- Defined in:
- lib/parse/atlas_search.rb,
lib/parse/atlas_search/result.rb,
lib/parse/atlas_search/session.rb,
lib/parse/atlas_search/index_manager.rb,
lib/parse/atlas_search/search_builder.rb
Overview
Requires the ‘mongo’ gem and a MongoDB Atlas cluster with Search enabled. Also works with local Atlas deployments created via ‘atlas deployments setup –type local`.
Atlas Search module for MongoDB Atlas full-text search capabilities. Provides direct access to Atlas Search features bypassing Parse Server.
Defined Under Namespace
Modules: IndexManager, Session Classes: ACLRequired, AutocompleteResult, FacetedResult, FacetedSearchNotACLSafe, IndexNotFound, InvalidSearchParameters, NotAvailable, SearchBuilder, SearchResult
Class Attribute Summary collapse
-
.allow_raw ⇒ Boolean
Whether ‘raw: true` is honored on AtlasSearch.search, AtlasSearch.autocomplete, and AtlasSearch.faceted_search.
-
.default_index ⇒ String
Default search index name to use when none specified.
-
.enabled ⇒ Boolean
Feature flag to enable/disable Atlas Search.
-
.require_session_token ⇒ Boolean
When
true, AtlasSearch.search, AtlasSearch.autocomplete, and AtlasSearch.faceted_search raise ACLRequired unless the caller passes eithersession_token:or master: true. -
.role_cache ⇒ #get, ...
Pluggable cache for Session‘s role-name lookups.
-
.role_cache_ttl ⇒ Integer
TTL (seconds) for Session‘s user-id → role-name cache.
-
.session_cache ⇒ #get, ...
Pluggable cache for Session‘s session-token lookups.
-
.session_cache_ttl ⇒ Integer
TTL (seconds) for Session‘s session-token → user-id cache.
Class Method Summary collapse
-
.autocomplete(collection_name, query, field:, **options) ⇒ Parse::AtlasSearch::AutocompleteResult
Perform an autocomplete search for search-as-you-type functionality.
-
.available? ⇒ Boolean
Check if Atlas Search is available and enabled.
-
.configure(enabled: true, default_index: "default", allow_raw: nil, require_session_token: nil, session_cache_ttl: nil, role_cache_ttl: nil) ⇒ Object
Configure Atlas Search (uses Parse::MongoDB connection).
-
.enabled? ⇒ Boolean
Check if Atlas Search is enabled.
-
.faceted_search(collection_name, query, facets, **options) ⇒ Parse::AtlasSearch::FacetedResult
Perform a faceted search with category counts.
-
.index_ready?(collection_name, index_name = nil) ⇒ Boolean
Check if a search index exists and is ready.
-
.indexes(collection_name) ⇒ Array<Hash>
List search indexes for a collection (cached).
-
.refresh_indexes(collection_name = nil) ⇒ Object
Force refresh the index cache for a collection.
-
.reset! ⇒ Object
Reset Atlas Search configuration to first-load defaults.
-
.search(collection_name, query, **options) ⇒ Parse::AtlasSearch::SearchResult
Perform a full-text search using Atlas Search.
Class Attribute Details
.allow_raw ⇒ Boolean
Whether ‘raw: true` is honored on search, autocomplete, and faceted_search. When `false` (the default), `raw:` is ignored and callers receive converted Parse-format documents. Even when `true`, internal-fields denylist (cf. PipelineSecurity::INTERNAL_FIELDS_DENYLIST) is ALWAYS stripped — there is no path that returns `_hashed_password`, `_session_token`, etc., regardless of `raw:`.
79 80 81 |
# File 'lib/parse/atlas_search.rb', line 79 def allow_raw @allow_raw end |
.default_index ⇒ String
Default search index name to use when none specified.
67 68 69 |
# File 'lib/parse/atlas_search.rb', line 67 def default_index @default_index end |
.enabled ⇒ Boolean
Feature flag to enable/disable Atlas Search.
62 63 64 |
# File 'lib/parse/atlas_search.rb', line 62 def enabled @enabled end |
.require_session_token ⇒ Boolean
When true, search, autocomplete, and faceted_search raise ACLRequired unless the caller passes either session_token: or master: true. Default: false, matching the pre-ACL behavior — a one-time [Parse::AtlasSearch:SECURITY] banner is emitted instead for missing-token calls, the same pattern used by Parse::Agent for master-key construction.
New deployments are strongly encouraged to flip this to true at startup. The next major release will flip the default.
94 95 96 |
# File 'lib/parse/atlas_search.rb', line 94 def require_session_token @require_session_token end |
.role_cache ⇒ #get, ...
Pluggable cache for Session‘s role-name lookups. See session_cache for the interface contract.
128 129 130 |
# File 'lib/parse/atlas_search.rb', line 128 def role_cache @role_cache end |
.role_cache_ttl ⇒ Integer
TTL (seconds) for Session‘s user-id → role-name cache. Default: 120 (2 minutes). Short on purpose: stale role data yields incorrect ACL decisions, so the cache is sized to amortize within a single request/turn but expire well inside the response time the operator notices a role grant.
113 114 115 |
# File 'lib/parse/atlas_search.rb', line 113 def role_cache_ttl @role_cache_ttl end |
.session_cache ⇒ #get, ...
Pluggable cache for Session‘s session-token lookups. Replace with a Redis/Memcached adapter for cross-process sharing; the object must respond to get(key), set(key, value, ttl:), and invalidate(key). Defaults to a process-local Parse::AtlasSearch::Session::MemoryCache.
122 123 124 |
# File 'lib/parse/atlas_search.rb', line 122 def session_cache @session_cache end |
.session_cache_ttl ⇒ Integer
TTL (seconds) for Session‘s session-token → user-id cache. Default: 3600 (1 hour). Longer values reduce /users/me round-trips but extend the window during which a revoked session can still authenticate Atlas Search calls; apps with sub-TTL revocation requirements should call Parse::AtlasSearch::Session.invalidate from their logout path.
104 105 106 |
# File 'lib/parse/atlas_search.rb', line 104 def session_cache_ttl @session_cache_ttl end |
Class Method Details
.autocomplete(collection_name, query, field:, **options) ⇒ Parse::AtlasSearch::AutocompleteResult
Perform an autocomplete search for search-as-you-type functionality.
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 |
# File 'lib/parse/atlas_search.rb', line 402 def autocomplete(collection_name, query, field:, **) require_available! raise InvalidSearchParameters, "field is required for autocomplete" if field.nil? raise InvalidSearchParameters, "query must be a non-empty string" if query.nil? || query.to_s.strip.empty? # Wave-3b READPREF-4: see #search for rationale. read_preference = .delete(:read_preference) resolution = resolve_scope!(, method_name: :autocomplete) # Enforce CLP `find` (and pointerFields requirement) on the same # collection autocomplete is about to scan. Without this an # autocomplete UI on a protected class would silently surface # the protected field's leading characters to any caller. assert_clp_find!(collection_name, resolution) pointer_fields = resolve_pointer_fields!(collection_name, resolution) # ATLAS-4: refuse autocomplete on a protected field. The # autocomplete operator returns the leading characters of the # indexed field value verbatim — running autocomplete on, say, # `email` when CLP marks `email` protected would defeat the # protectedFields contract. protected_fields = Parse::CLPScope.protected_fields_for( collection_name, resolution., ) field_str = field.to_s if !resolution.master? && protected_fields.include?(field_str) raise Parse::CLPScope::Denied.new( collection_name, :find, "Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \ "protectedFields for the current scope; autocompleting on it would " \ "leak the protected field's value.", ) end index_name = [:index] || @default_index limit = [:limit] || 10 # Build autocomplete search stage builder = SearchBuilder.new(index_name: index_name) builder.autocomplete( query: query.to_s, path: field_str, fuzzy: [:fuzzy], token_order: [:token_order], ) # CRITICAL: $search MUST be stage 0 of the pipeline (see # comments in #search). Build manually; do NOT route through # Parse::MongoDB.aggregate (which would prepend an ACL $match # at position 0 and break Atlas's invariant). pipeline = [builder.build] # Add score pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } } # Inject ACL $match AFTER $search/$addFields and BEFORE the # caller-supplied filter; see {.search} for the rationale. unless resolution.master? acl_match = Parse::ACLScope.match_stage_for(resolution) pipeline << acl_match if acl_match end # Add filter if provided if [:filter] mongo_filter = convert_filter_for_mongodb([:filter], collection_name) pipeline << { "$match" => mongo_filter } end # Sort by score and limit pipeline << { "$sort" => { "_score" => -1 } } pipeline << { "$limit" => limit } raw_results = run_atlas_pipeline!( collection_name, pipeline, [:max_time_ms], read_preference: read_preference, ) unless resolution.master? Parse::ACLScope.redact_results!(raw_results, resolution) Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any? if pointer_fields raw_results = Parse::CLPScope.filter_by_pointer_fields( raw_results, pointer_fields, resolution.user_id, ) end end # Extract suggestions (the field values). Run after the # protectedFields strip / pointerFields filter so a redacted # row can't surface its field value through the suggestion list. suggestions = raw_results.map { |doc| doc[field_str] }.compact.uniq # Convert to full objects if needed class_name = [:class_name] || collection_name results = if raw_mode?([:raw]) sanitize_raw_results(raw_results) else parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name) parse_results.map { |doc| build_parse_object(doc, class_name) }.compact end AutocompleteResult.new(suggestions: suggestions, results: results) end |
.available? ⇒ Boolean
Check if Atlas Search is available and enabled
178 179 180 181 |
# File 'lib/parse/atlas_search.rb', line 178 def available? return false unless defined?(Parse::MongoDB) Parse::MongoDB.available? && enabled? end |
.configure(enabled: true, default_index: "default", allow_raw: nil, require_session_token: nil, session_cache_ttl: nil, role_cache_ttl: nil) ⇒ Object
Configure Atlas Search (uses Parse::MongoDB connection)
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/parse/atlas_search.rb', line 147 def configure(enabled: true, default_index: "default", allow_raw: nil, require_session_token: nil, session_cache_ttl: nil, role_cache_ttl: nil) Parse::MongoDB.require_gem! @enabled = enabled @default_index = default_index @allow_raw = allow_raw.nil? ? default_allow_raw : allow_raw @require_session_token = require_session_token unless require_session_token.nil? @session_cache_ttl = session_cache_ttl unless session_cache_ttl.nil? @role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil? IndexManager.clear_cache end |
.enabled? ⇒ Boolean
Check if Atlas Search is enabled
185 186 187 |
# File 'lib/parse/atlas_search.rb', line 185 def enabled? @enabled == true end |
.faceted_search(collection_name, query, facets, **options) ⇒ Parse::AtlasSearch::FacetedResult
Perform a faceted search with category counts.
523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 |
# File 'lib/parse/atlas_search.rb', line 523 def faceted_search(collection_name, query, facets, **) require_available! # Faceted search uses $searchMeta, which outputs a single # metadata document — bucket counts can't be retroactively # filtered by a post-$searchMeta $match because the matched # documents are not in the output stream. ACL-aware faceting # requires either tokenizing _rperm in the search index and # injecting a compound.filter inside $searchMeta, or running # two passes with manual aggregation. Both are deferred. # # Library-layer defense: refuse ANY scoped identity kwarg # (session_token:, acl_user:, acl_role:) unless the caller # explicitly accepts master-key semantics by also passing # `master: true`. The original code only checked # `session_token:`, leaving `acl_user:` / `acl_role:` callers # (ATLAS-10) silently downgraded to the unauthenticated/ # public-mode banner branch — which on $searchMeta produces # bucket counts that include rows the caller cannot read, # exfiltrating restricted document counts and category # values. Checking the raw options BEFORE resolve_scope! # pops them so the error path can name what the caller # actually passed. scoped_kwargs = %i[session_token acl_user acl_role] offending = scoped_kwargs.select { |k| ![k].nil? } if offending.any? && [:master] != true raise FacetedSearchNotACLSafe, "Parse::AtlasSearch.faceted_search cannot enforce per-row " \ "ACL on $searchMeta bucket counts (got #{offending.first}:). " \ "Pass `master: true` to run with master-key semantics and " \ "accept that bucket counts include all rows, or use " \ "#search for ACL-scoped results without facets." end # Wave-3b READPREF-4: see #search for rationale. Captured # before resolve_scope! pops the auth kwargs so the recursive # search() call below can re-thread it explicitly (resolve! # also strips it during that recursion). read_preference = .delete(:read_preference) resolution = resolve_scope!(, method_name: :faceted_search) acl = { master: resolution.master? } index_name = [:index] || @default_index limit = [:limit] || 100 skip_val = [:skip] || 0 # Build facet definitions for $searchMeta facet_definitions = build_facet_definitions(facets) = { "$searchMeta" => { "index" => index_name, "facet" => { "facets" => facet_definitions, }, }, } # Add operator for the search query if present if query.present? fields = normalize_fields([:fields]) if fields.present? should_clauses = fields.map do |field| { "text" => { "query" => query, "path" => field } } end ["$searchMeta"]["facet"]["operator"] = { "compound" => { "should" => should_clauses, "minimumShouldMatch" => 1 }, } else ["$searchMeta"]["facet"]["operator"] = { "text" => { "query" => query, "path" => { "wildcard" => "*" } }, } end end # Execute facet query. $searchMeta MUST be the only / first # stage of its pipeline — Atlas rejects anything prepended. # Bypass Parse::MongoDB.aggregate (which would prepend a # public-mode ACL $match at position 0 under the no-auth-kwargs # fallthrough) and call the collection directly. At this point # the call is master-only by construction (the offending-kwargs # check above ensures any scoped caller bailed out), so no # ACL/CLP enforcement runs here either. facet_pipeline = [] facet_results_raw = run_atlas_pipeline!( collection_name, facet_pipeline, [:max_time_ms], read_preference: read_preference, ) # Extract facet results facet_data = {} total_count = 0 if facet_results_raw.first raw = facet_results_raw.first total_count = raw.dig("count", "total") || 0 if raw["facet"] facets.keys.each do |facet_name| bucket_key = facet_name.to_s if raw["facet"][bucket_key] facet_data[facet_name] = raw["facet"][bucket_key]["buckets"].map do |bucket| { value: bucket["_id"], count: bucket["count"] } end end end end end # Get actual results with regular $search. Forward master: # explicitly because resolve_acl_options! popped it from the # options hash; without re-adding it the recursive call would # take the unauthenticated path and emit the banner a second # time (or raise ACLRequired under strict mode). Re-thread # read_preference: the same way for the same reason — the # outer faceted_search popped it before delegating. results = if limit > 0 && query.present? search_opts = .merge(limit: limit, skip: skip_val) search_opts[:master] = true if acl[:master] search_opts[:read_preference] = read_preference if read_preference search(collection_name, query, **search_opts).results else [] end FacetedResult.new(results: results, facets: facet_data, total_count: total_count) end |
.index_ready?(collection_name, index_name = nil) ⇒ Boolean
Check if a search index exists and is ready
216 217 218 |
# File 'lib/parse/atlas_search.rb', line 216 def index_ready?(collection_name, index_name = nil) IndexManager.index_ready?(collection_name, index_name || @default_index) end |
.indexes(collection_name) ⇒ Array<Hash>
List search indexes for a collection (cached)
208 209 210 |
# File 'lib/parse/atlas_search.rb', line 208 def indexes(collection_name) IndexManager.list_indexes(collection_name) end |
.refresh_indexes(collection_name = nil) ⇒ Object
Force refresh the index cache for a collection
222 223 224 |
# File 'lib/parse/atlas_search.rb', line 222 def refresh_indexes(collection_name = nil) IndexManager.clear_cache(collection_name) end |
.reset! ⇒ Object
Reset Atlas Search configuration to first-load defaults. Clears the session/role caches as well; this is primarily a test helper.
192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/parse/atlas_search.rb', line 192 def reset! @enabled = false @default_index = "default" @allow_raw = default_allow_raw @require_session_token = false @session_cache_ttl = 3600 @role_cache_ttl = 120 @session_cache = Session::MemoryCache.new @role_cache = Session::MemoryCache.new @master_warned = false IndexManager.clear_cache end |
.search(collection_name, query, **options) ⇒ Parse::AtlasSearch::SearchResult
Perform a full-text search using Atlas Search.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/parse/atlas_search.rb', line 259 def search(collection_name, query, **) require_available! validate_search_params!(query) # Wave-3b READPREF-4: read-preference is consumed at the # collection-with-read-preference step inside run_atlas_pipeline!. # Pop it here so it doesn't surface in `options` for any # downstream consumer (SearchBuilder, recursive search() # call from faceted_search) that iterates the hash. read_preference = .delete(:read_preference) resolution = resolve_scope!(, method_name: :search) # Enforce CLP `find` (and pointerFields requirement) BEFORE # we build / execute the pipeline. Without this, a scoped # caller can issue $search against a collection whose CLP # would refuse them on the equivalent REST find. assert_clp_find!(collection_name, resolution) pointer_fields = resolve_pointer_fields!(collection_name, resolution) # Compute the protectedFields strip set early so we can # refuse a highlight_field that's in it (ATLAS-4). Avoids # the awkward "we return objects but secretly drop their # highlights" state — fail loudly instead. protected_fields = Parse::CLPScope.protected_fields_for( collection_name, resolution., ) assert_highlight_field_allowed!([:highlight_field], protected_fields, resolution) index_name = [:index] || @default_index fields = normalize_fields([:fields]) limit = [:limit] || 100 skip_val = [:skip] || 0 # Build the $search stage builder = SearchBuilder.new(index_name: index_name) if fields.present? fields.each do |field| builder.text(query: query, path: field, fuzzy: [:fuzzy]) end else builder.text(query: query, path: { "wildcard" => "*" }, fuzzy: [:fuzzy]) end if [:highlight_field] builder.with_highlight(path: [:highlight_field]) end # CRITICAL: $search MUST be stage 0 of an Atlas Search # pipeline. MongoDB Atlas rejects pipelines whose first stage # is anything other than $search/$searchMeta. Do NOT route # through Parse::MongoDB.aggregate here — that helper prepends # the ACL $match to position 0, which Atlas would reject. We # build the pipeline manually with $search at index 0 and # place the ACL $match AFTER $search (which is correct: $search # has already produced its candidate set, the $match narrows it # to ACL-readable rows, then the caller filter narrows further). pipeline = [builder.build] # Add score projection pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } } # Add highlights projection if requested if [:highlight_field] pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } } end # Inject ACL $match BEFORE the caller-supplied filter (but AFTER # $search and the $addFields stages) so the user-controlled # filter cannot exfiltrate restricted documents that passed the # $search operator. The $exists: false branch in `read_predicate` # covers documents Parse Server treats as public (no _rperm). unless resolution.master? acl_match = Parse::ACLScope.match_stage_for(resolution) pipeline << acl_match if acl_match end # Add filter stage if provided if [:filter] mongo_filter = convert_filter_for_mongodb([:filter], collection_name) pipeline << { "$match" => mongo_filter } end # Add sort (default by score) sort_spec = [:sort] || { "_score" => -1 } pipeline << { "$sort" => sort_spec } # Add pagination pipeline << { "$skip" => skip_val } if skip_val > 0 pipeline << { "$limit" => limit } # Execute directly against the MongoDB collection — bypasses # Parse::MongoDB.aggregate so its ACL-prepend doesn't violate # the $search-at-stage-0 invariant. We're reproducing the # SDK-side enforcement chain (ACL match, protectedFields strip, # pointerFields filter, embedded sub-doc redaction) inline below. raw_results = run_atlas_pipeline!( collection_name, pipeline, [:max_time_ms], read_preference: read_preference, ) # Post-fetch enforcement: walk the result rows the same way # Parse::MongoDB.aggregate would. Master mode is the ACL bypass # — skip every redaction layer (matches the helper's behavior). unless resolution.master? Parse::ACLScope.redact_results!(raw_results, resolution) Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any? if pointer_fields raw_results = Parse::CLPScope.filter_by_pointer_fields( raw_results, pointer_fields, resolution.user_id, ) end # ATLAS-4: drop any `_highlights` entry whose `path` names a # protected field. `searchHighlights` returns the matched # token plus its surrounding text, which would otherwise leak # the protected field's value through the snippet. strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any? end # Convert results class_name = [:class_name] || collection_name process_search_results(raw_results, class_name, [:raw]) end |