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,163 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "model/core/errors"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
# LiveQuery provides real-time data subscriptions for reactive applications.
|
|
8
|
+
# It uses WebSockets to receive push notifications when data changes on the server.
|
|
9
|
+
#
|
|
10
|
+
# @note EXPERIMENTAL: This feature is not fully implemented. The WebSocket client
|
|
11
|
+
# is incomplete. You must explicitly enable this feature before use:
|
|
12
|
+
#
|
|
13
|
+
# Parse.live_query_enabled = true
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# # Configure LiveQuery server URL
|
|
17
|
+
# Parse.setup(
|
|
18
|
+
# application_id: "your_app_id",
|
|
19
|
+
# api_key: "your_api_key",
|
|
20
|
+
# server_url: "https://your-parse-server.com/parse",
|
|
21
|
+
# live_query_url: "wss://your-parse-server.com"
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# # Subscribe to changes on a model
|
|
25
|
+
# subscription = Song.subscribe(where: { artist: "Artist Name" })
|
|
26
|
+
#
|
|
27
|
+
# subscription.on(:create) { |song| puts "New song: #{song.title}" }
|
|
28
|
+
# subscription.on(:update) { |song, original| puts "Updated: #{song.title}" }
|
|
29
|
+
# subscription.on(:delete) { |song| puts "Deleted: #{song.id}" }
|
|
30
|
+
# subscription.on(:enter) { |song, original| puts "Entered query: #{song.title}" }
|
|
31
|
+
# subscription.on(:leave) { |song, original| puts "Left query: #{song.title}" }
|
|
32
|
+
#
|
|
33
|
+
# # Unsubscribe when done
|
|
34
|
+
# subscription.unsubscribe
|
|
35
|
+
#
|
|
36
|
+
# @example Using Query directly
|
|
37
|
+
# query = Song.query(:plays.gt => 1000)
|
|
38
|
+
# subscription = query.subscribe
|
|
39
|
+
#
|
|
40
|
+
# subscription.on_create { |song| puts "New popular song!" }
|
|
41
|
+
# subscription.on_update { |song| puts "Song updated!" }
|
|
42
|
+
#
|
|
43
|
+
# @example Multiple subscriptions
|
|
44
|
+
# client = Parse::LiveQuery.client
|
|
45
|
+
#
|
|
46
|
+
# sub1 = client.subscribe(Song, where: { genre: "rock" })
|
|
47
|
+
# sub2 = client.subscribe(Album, where: { year: 2024 })
|
|
48
|
+
#
|
|
49
|
+
# # Close all subscriptions
|
|
50
|
+
# client.close
|
|
51
|
+
#
|
|
52
|
+
module LiveQuery
|
|
53
|
+
# Base error class for LiveQuery. Inherits from Parse::Error so that
|
|
54
|
+
# `rescue Parse::Error` will also catch LiveQuery failures.
|
|
55
|
+
class Error < Parse::Error; end
|
|
56
|
+
class ConnectionError < Error; end
|
|
57
|
+
class SubscriptionError < Error; end
|
|
58
|
+
class AuthenticationError < Error; end
|
|
59
|
+
|
|
60
|
+
# Default LiveQuery events
|
|
61
|
+
EVENTS = %i[create update delete enter leave].freeze
|
|
62
|
+
|
|
63
|
+
# Error raised when LiveQuery is used but not enabled
|
|
64
|
+
class NotEnabledError < Error
|
|
65
|
+
def initialize
|
|
66
|
+
super("LiveQuery is experimental and must be explicitly enabled. Set Parse.live_query_enabled = true")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Require components after module and error classes are defined
|
|
73
|
+
require_relative "live_query/configuration"
|
|
74
|
+
require_relative "live_query/logging"
|
|
75
|
+
require_relative "live_query/event"
|
|
76
|
+
require_relative "live_query/health_monitor"
|
|
77
|
+
require_relative "live_query/circuit_breaker"
|
|
78
|
+
require_relative "live_query/event_queue"
|
|
79
|
+
require_relative "live_query/subscription"
|
|
80
|
+
require_relative "live_query/client"
|
|
81
|
+
|
|
82
|
+
module Parse
|
|
83
|
+
module LiveQuery
|
|
84
|
+
class << self
|
|
85
|
+
# @return [Parse::LiveQuery::Client] the default LiveQuery client
|
|
86
|
+
attr_accessor :default_client
|
|
87
|
+
|
|
88
|
+
# Check if LiveQuery feature is enabled
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def enabled?
|
|
91
|
+
Parse.live_query_enabled?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Ensure LiveQuery is enabled, raising an error if not
|
|
95
|
+
# @raise [NotEnabledError] if LiveQuery is not enabled
|
|
96
|
+
def ensure_enabled!
|
|
97
|
+
raise NotEnabledError unless enabled?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get or create the default LiveQuery client.
|
|
101
|
+
# Uses the configuration from Parse.setup if available.
|
|
102
|
+
# @return [Parse::LiveQuery::Client]
|
|
103
|
+
# @raise [NotEnabledError] if LiveQuery is not enabled
|
|
104
|
+
def client
|
|
105
|
+
ensure_enabled!
|
|
106
|
+
@default_client ||= Client.new
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reset the default client (closes connection and clears instance)
|
|
110
|
+
def reset!
|
|
111
|
+
@default_client&.close
|
|
112
|
+
@default_client = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if LiveQuery is configured and available
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def available?
|
|
118
|
+
!!config.url
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get the LiveQuery configuration object
|
|
122
|
+
# @return [Parse::LiveQuery::Configuration]
|
|
123
|
+
def config
|
|
124
|
+
@config ||= Configuration.new
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Configure LiveQuery settings using a block
|
|
128
|
+
# @yield [config] Configuration object
|
|
129
|
+
# @return [Configuration]
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
# Parse::LiveQuery.configure do |config|
|
|
133
|
+
# config.url = "wss://your-server.com"
|
|
134
|
+
# config.ping_interval = 20.0
|
|
135
|
+
# config.logging_enabled = true
|
|
136
|
+
# end
|
|
137
|
+
def configure
|
|
138
|
+
yield config if block_given?
|
|
139
|
+
|
|
140
|
+
# Sync logging settings
|
|
141
|
+
if config.logging_enabled
|
|
142
|
+
Logging.enabled = true
|
|
143
|
+
Logging.log_level = config.log_level
|
|
144
|
+
Logging.logger = config.logger if config.logger
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
config
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Legacy configuration method for backward compatibility
|
|
151
|
+
# @deprecated Use configure block instead
|
|
152
|
+
# @return [Hash]
|
|
153
|
+
def configuration
|
|
154
|
+
{
|
|
155
|
+
url: config.url,
|
|
156
|
+
application_id: config.application_id,
|
|
157
|
+
client_key: config.client_key,
|
|
158
|
+
master_key: config.master_key,
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "model/model"
|
|
5
|
+
require_relative "model/core/parse_reference"
|
|
6
|
+
require_relative "pipeline_security"
|
|
7
|
+
|
|
8
|
+
module Parse
|
|
9
|
+
# Translate "LLM-style" MongoDB `$lookup` stages -- expressed against logical
|
|
10
|
+
# Parse class names and pretty field names -- into the column-name form that
|
|
11
|
+
# Parse Server actually uses in MongoDB.
|
|
12
|
+
#
|
|
13
|
+
# An LLM trained on standard MongoDB syntax will produce a lookup like:
|
|
14
|
+
#
|
|
15
|
+
# { "$lookup" => { "from" => "Project", "localField" => "project",
|
|
16
|
+
# "foreignField" => "_id", "as" => "project_doc" } }
|
|
17
|
+
#
|
|
18
|
+
# That never matches anything, because Parse stores the join column as
|
|
19
|
+
# `_p_project` (containing the pointer string `"Project$abc123"`) and the
|
|
20
|
+
# foreign `_id` is just `"abc123"`. When the foreign class declares
|
|
21
|
+
# `parse_reference`, the column `parseReference` mirrors the pointer-string
|
|
22
|
+
# form, so the join collapses to a single-field equality:
|
|
23
|
+
#
|
|
24
|
+
# { "$lookup" => { "from" => "Project", "localField" => "_p_project",
|
|
25
|
+
# "foreignField" => "parseReference", "as" => "project_doc" } }
|
|
26
|
+
#
|
|
27
|
+
# When the foreign class does NOT declare `parse_reference`, the rewriter
|
|
28
|
+
# falls back to the `let`/`pipeline`/`$split` form that extracts the objectId
|
|
29
|
+
# from `_p_*` and matches it against the foreign `_id`.
|
|
30
|
+
#
|
|
31
|
+
# == Design
|
|
32
|
+
#
|
|
33
|
+
# The rewriter is intentionally a stand-alone helper, not auto-wired into
|
|
34
|
+
# `Parse::Query#aggregate` or `Parse::MongoDB.aggregate`. Existing SDK code
|
|
35
|
+
# writes `$lookup` against `_p_*`/`parseReference` directly and silently
|
|
36
|
+
# rewriting those would corrupt them. The intended consumer is the LLM
|
|
37
|
+
# tool dispatcher (`Parse::Agent::Tools.aggregate`) where pipelines are
|
|
38
|
+
# generated by a model that doesn't know Parse's storage layout.
|
|
39
|
+
#
|
|
40
|
+
# == What is rewritten
|
|
41
|
+
#
|
|
42
|
+
# For each `$lookup` stage with `localField` + `foreignField`:
|
|
43
|
+
#
|
|
44
|
+
# 1. **Forward join** (local class has `belongs_to :foo`):
|
|
45
|
+
# localField: "foo" -> "_p_foo"
|
|
46
|
+
# foreignField: "_id"|"objectId" -> "parseReference" (or $split fallback)
|
|
47
|
+
#
|
|
48
|
+
# 2. **Reverse join** (foreign class has `belongs_to` pointing back at us):
|
|
49
|
+
# localField: "_id"|"objectId" -> "parseReference" (or $split fallback)
|
|
50
|
+
# foreignField: "<pointer_name>" -> "_p_<pointer_name>"
|
|
51
|
+
#
|
|
52
|
+
# 3. **System class collection rename** (always applied):
|
|
53
|
+
# from: "User" -> "_User" (also _Role, _Installation, _Session)
|
|
54
|
+
#
|
|
55
|
+
# 4. **Sub-pipeline recursion**: `$lookup.pipeline`, `$unionWith.pipeline`,
|
|
56
|
+
# and `$facet.*` are recursively rewritten with the foreign class (or
|
|
57
|
+
# the original local class for `$facet`) as the new local context.
|
|
58
|
+
#
|
|
59
|
+
# == What is NOT rewritten
|
|
60
|
+
#
|
|
61
|
+
# - Stages already in `_p_*`/`parseReference` form (idempotency).
|
|
62
|
+
# - Lookups whose `localField` is neither a known belongs_to nor an identity
|
|
63
|
+
# alias matched by a reverse belongs_to.
|
|
64
|
+
# - Lookups in `let`/`pipeline` form without a `localField`/`foreignField`
|
|
65
|
+
# pair (those are constructed deliberately; only the `from` collection is
|
|
66
|
+
# renamed and the sub-pipeline is recursed).
|
|
67
|
+
# - Embedded-pointer-array joins -- by user request, since the array entries
|
|
68
|
+
# already carry `__type`/`className` and don't benefit from
|
|
69
|
+
# `parseReference`. These fall through naturally because the join field
|
|
70
|
+
# isn't a belongs_to on either side.
|
|
71
|
+
module LookupRewriter
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
# Logical-name -> Parse-on-Mongo collection-name aliases for the four
|
|
75
|
+
# system classes. The LLM will write `from: "User"`; Mongo wants `_User`.
|
|
76
|
+
SYSTEM_CLASS_MAP = {
|
|
77
|
+
"User" => Parse::Model::CLASS_USER,
|
|
78
|
+
"Installation" => Parse::Model::CLASS_INSTALLATION,
|
|
79
|
+
"Role" => Parse::Model::CLASS_ROLE,
|
|
80
|
+
"Session" => Parse::Model::CLASS_SESSION,
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# Foreign-field values that an LLM might write to mean "the object's
|
|
84
|
+
# identity". Either is accepted as input; the rewriter substitutes
|
|
85
|
+
# the appropriate Parse-on-Mongo column.
|
|
86
|
+
OBJECT_ID_ALIASES = %w[_id objectId].freeze
|
|
87
|
+
|
|
88
|
+
# Parse-on-Mongo remote column name for the `parse_reference` DSL.
|
|
89
|
+
# Matches the default `field_map` entry produced by
|
|
90
|
+
# `property :parse_reference, :string, field: "parseReference"`.
|
|
91
|
+
PARSE_REFERENCE_REMOTE = "parseReference"
|
|
92
|
+
|
|
93
|
+
# Auto-rewrite a pipeline for one of the gem's three aggregation entry
|
|
94
|
+
# points (`Parse::Query#aggregate`, `Parse::MongoDB.aggregate`,
|
|
95
|
+
# `Parse::Agent::Tools.aggregate`). Resolves `class_name` to a
|
|
96
|
+
# `Parse::Object` subclass and forwards to {.rewrite} with
|
|
97
|
+
# `fallback: :preserve` -- the auto path only rewrites stages where the
|
|
98
|
+
# foreign class declares `parse_reference`, so SDK-generated pipelines
|
|
99
|
+
# (already in `_p_*`/`parseReference` form) and pipelines whose foreign
|
|
100
|
+
# class lacks `parse_reference` pass through unchanged.
|
|
101
|
+
#
|
|
102
|
+
# @param pipeline [Array<Hash>] the caller-supplied pipeline
|
|
103
|
+
# @param class_name [String, Symbol] the Parse class the aggregation runs against
|
|
104
|
+
# @param enabled [Boolean, nil] explicit override. `nil` (the default)
|
|
105
|
+
# reads `Parse.rewrite_lookups`.
|
|
106
|
+
# @return [Array<Hash>] rewritten pipeline, or the input unchanged if
|
|
107
|
+
# rewriting is disabled or the class can't be resolved.
|
|
108
|
+
def auto_rewrite(pipeline, class_name:, enabled: nil)
|
|
109
|
+
return pipeline unless pipeline.is_a?(Array)
|
|
110
|
+
flag = enabled.nil? ? Parse.rewrite_lookups : enabled
|
|
111
|
+
return pipeline unless flag
|
|
112
|
+
klass = Parse::Model.find_class(class_name.to_s) rescue nil
|
|
113
|
+
return pipeline unless klass
|
|
114
|
+
rewrite(pipeline, local_class: klass, fallback: :preserve)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Walk a top-level pipeline and return a rewritten copy. Non-Array
|
|
118
|
+
# inputs are returned untouched.
|
|
119
|
+
#
|
|
120
|
+
# @param pipeline [Array<Hash>] aggregation pipeline
|
|
121
|
+
# @param local_class [Class<Parse::Object>] the class the outer aggregation
|
|
122
|
+
# is running against. Used to resolve forward `belongs_to` pointer
|
|
123
|
+
# fields and to compute its own `parseReference` for reverse joins.
|
|
124
|
+
# @param fallback [Symbol] what to do when a lookup is rewriteable in
|
|
125
|
+
# shape (matches a `belongs_to`) but the target class lacks
|
|
126
|
+
# `parse_reference`:
|
|
127
|
+
# - `:split` (default) -- emit the `let`/`pipeline`/`$split` form.
|
|
128
|
+
# Always produces a working join.
|
|
129
|
+
# - `:preserve` -- leave the stage alone. Use when the caller wants
|
|
130
|
+
# the rewriter to act only as an optimization, not as a
|
|
131
|
+
# correction. This is the mode used by the gem's auto-wired
|
|
132
|
+
# paths so SDK-generated pipelines (which the rewriter shouldn't
|
|
133
|
+
# second-guess) survive untouched.
|
|
134
|
+
# @return [Array<Hash>] a new pipeline with eligible `$lookup` stages
|
|
135
|
+
# rewritten. The input is not mutated.
|
|
136
|
+
def rewrite(pipeline, local_class:, fallback: :split)
|
|
137
|
+
return pipeline unless pipeline.is_a?(Array)
|
|
138
|
+
pipeline.map { |stage| rewrite_stage(stage, local_class: local_class, fallback: fallback) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Rewrite a single stage. Stages that are not `$lookup`/`$unionWith`/
|
|
142
|
+
# `$facet` are returned unchanged. Sub-pipelines inside those three are
|
|
143
|
+
# rewritten recursively.
|
|
144
|
+
def rewrite_stage(stage, local_class:, fallback: :split)
|
|
145
|
+
return stage unless stage.is_a?(Hash)
|
|
146
|
+
stage.each_with_object({}) do |(key, value), out|
|
|
147
|
+
case key.to_s
|
|
148
|
+
when "$lookup"
|
|
149
|
+
out[key] = rewrite_lookup(value, local_class: local_class, fallback: fallback)
|
|
150
|
+
when "$graphLookup"
|
|
151
|
+
out[key] = rewrite_graph_lookup(value, local_class: local_class)
|
|
152
|
+
when "$unionWith"
|
|
153
|
+
out[key] = rewrite_union_with(value, local_class: local_class, fallback: fallback)
|
|
154
|
+
when "$facet"
|
|
155
|
+
out[key] = rewrite_facet(value, local_class: local_class, fallback: fallback)
|
|
156
|
+
else
|
|
157
|
+
out[key] = value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `$graphLookup` doesn't accept a `pipeline:` form, only `from:`,
|
|
163
|
+
# `startWith:`, `connectFromField:`, `connectToField:`, `as:`, plus a
|
|
164
|
+
# few options. We rewrite only the collection name (system-class
|
|
165
|
+
# alias) here. Pointer-style `_p_*`/parseReference equality across the
|
|
166
|
+
# connect-fields would require knowing both fields are pointer columns
|
|
167
|
+
# on both sides -- the typical $graphLookup use cases (recursive
|
|
168
|
+
# hierarchies over the same collection) don't need it. Document this
|
|
169
|
+
# so callers using $graphLookup against tagged pointer columns supply
|
|
170
|
+
# the Parse-on-Mongo column names themselves.
|
|
171
|
+
def rewrite_graph_lookup(spec, local_class:)
|
|
172
|
+
return spec unless spec.is_a?(Hash)
|
|
173
|
+
from_logical = read_string(spec, "from")
|
|
174
|
+
from_collection = canonical_collection_name(from_logical)
|
|
175
|
+
Parse::PipelineSecurity.assert_collection_allowed!(from_collection)
|
|
176
|
+
rename_collection_only(spec, from_logical, from_collection)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Rewrite a single `$lookup` spec.
|
|
180
|
+
def rewrite_lookup(spec, local_class:, fallback: :split)
|
|
181
|
+
return spec unless spec.is_a?(Hash)
|
|
182
|
+
from_logical = read_string(spec, "from")
|
|
183
|
+
from_collection = canonical_collection_name(from_logical)
|
|
184
|
+
Parse::PipelineSecurity.assert_collection_allowed!(from_collection)
|
|
185
|
+
target_class = resolve_class(from_logical) || resolve_class(from_collection)
|
|
186
|
+
|
|
187
|
+
# let/pipeline shape -- only fix collection name and recurse into the
|
|
188
|
+
# sub-pipeline using the foreign class as its local context.
|
|
189
|
+
if has_key?(spec, "pipeline") && !has_key?(spec, "localField")
|
|
190
|
+
return rewrite_let_pipeline_form(spec, from_logical, from_collection, target_class, fallback)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
local_field = read_string(spec, "localField")
|
|
194
|
+
foreign_field = read_string(spec, "foreignField")
|
|
195
|
+
return rename_collection_only(spec, from_logical, from_collection) unless local_field && foreign_field
|
|
196
|
+
|
|
197
|
+
# Already in Parse-on-Mongo form -- leave untouched aside from the
|
|
198
|
+
# system-class collection rename.
|
|
199
|
+
if local_field.start_with?("_p_") || foreign_field == PARSE_REFERENCE_REMOTE
|
|
200
|
+
return rename_collection_only(spec, from_logical, from_collection)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
forward_field = resolve_forward_pointer(local_class, local_field)
|
|
204
|
+
if forward_field && OBJECT_ID_ALIASES.include?(foreign_field)
|
|
205
|
+
if foreign_has_parse_reference?(target_class)
|
|
206
|
+
return build_forward_rewrite(spec, forward_field, target_class, from_logical, from_collection)
|
|
207
|
+
elsif fallback == :split
|
|
208
|
+
return build_forward_rewrite(spec, forward_field, target_class, from_logical, from_collection)
|
|
209
|
+
else
|
|
210
|
+
return rename_collection_only(spec, from_logical, from_collection)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
reverse_field = resolve_reverse_pointer(target_class, foreign_field, local_class)
|
|
215
|
+
if reverse_field && OBJECT_ID_ALIASES.include?(local_field)
|
|
216
|
+
if foreign_has_parse_reference?(local_class)
|
|
217
|
+
return build_reverse_rewrite(spec, reverse_field, local_class, from_logical, from_collection)
|
|
218
|
+
elsif fallback == :split
|
|
219
|
+
return build_reverse_rewrite(spec, reverse_field, local_class, from_logical, from_collection)
|
|
220
|
+
else
|
|
221
|
+
return rename_collection_only(spec, from_logical, from_collection)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
rename_collection_only(spec, from_logical, from_collection)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------
|
|
229
|
+
# Forward / reverse builders
|
|
230
|
+
# ---------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
# Local: { _p_<field>: "Foreign$abc" } Foreign: { parseReference: "Foreign$abc" }
|
|
233
|
+
# When parse_reference is declared on the foreign class -> direct equality.
|
|
234
|
+
# Otherwise -> let/pipeline with $split extracting the objectId.
|
|
235
|
+
def build_forward_rewrite(spec, pointer_field, target_class, from_logical, from_collection)
|
|
236
|
+
mongo_local = "_p_#{pointer_field}"
|
|
237
|
+
if foreign_has_parse_reference?(target_class)
|
|
238
|
+
replace_keys(spec,
|
|
239
|
+
"from" => from_collection,
|
|
240
|
+
"localField" => mongo_local,
|
|
241
|
+
"foreignField" => PARSE_REFERENCE_REMOTE)
|
|
242
|
+
else
|
|
243
|
+
as_value = read_string(spec, "as")
|
|
244
|
+
let_var = "rwLookupId_#{pointer_field}"
|
|
245
|
+
spec_without_pair = drop_keys(spec, %w[localField foreignField pipeline let from])
|
|
246
|
+
spec_without_pair["from"] = from_collection
|
|
247
|
+
spec_without_pair["let"] = {
|
|
248
|
+
let_var => { "$arrayElemAt" => [{ "$split" => ["$#{mongo_local}", { "$literal" => "$" }] }, 1] },
|
|
249
|
+
}
|
|
250
|
+
spec_without_pair["pipeline"] = [
|
|
251
|
+
{ "$match" => { "$expr" => { "$eq" => ["$_id", "$$#{let_var}"] } } },
|
|
252
|
+
]
|
|
253
|
+
spec_without_pair["as"] = as_value if as_value
|
|
254
|
+
spec_without_pair
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Local: { parseReference: "Local$abc" } Foreign: { _p_<field>: "Local$abc" }
|
|
259
|
+
# When parse_reference is declared on the LOCAL class -> direct equality.
|
|
260
|
+
# Otherwise -> let/pipeline with $split on the foreign side.
|
|
261
|
+
def build_reverse_rewrite(spec, pointer_field, local_class, from_logical, from_collection)
|
|
262
|
+
mongo_foreign = "_p_#{pointer_field}"
|
|
263
|
+
if foreign_has_parse_reference?(local_class)
|
|
264
|
+
replace_keys(spec,
|
|
265
|
+
"from" => from_collection,
|
|
266
|
+
"localField" => PARSE_REFERENCE_REMOTE,
|
|
267
|
+
"foreignField" => mongo_foreign)
|
|
268
|
+
else
|
|
269
|
+
as_value = read_string(spec, "as")
|
|
270
|
+
let_var = "rwReverseId_#{pointer_field}"
|
|
271
|
+
spec_without_pair = drop_keys(spec, %w[localField foreignField pipeline let from])
|
|
272
|
+
spec_without_pair["from"] = from_collection
|
|
273
|
+
spec_without_pair["let"] = { let_var => "$_id" }
|
|
274
|
+
spec_without_pair["pipeline"] = [
|
|
275
|
+
{ "$match" => { "$expr" => {
|
|
276
|
+
"$eq" => [
|
|
277
|
+
{ "$arrayElemAt" => [{ "$split" => ["$#{mongo_foreign}", { "$literal" => "$" }] }, 1] },
|
|
278
|
+
"$$#{let_var}",
|
|
279
|
+
],
|
|
280
|
+
} } },
|
|
281
|
+
]
|
|
282
|
+
spec_without_pair["as"] = as_value if as_value
|
|
283
|
+
spec_without_pair
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------
|
|
288
|
+
# Stage helpers
|
|
289
|
+
# ---------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def rewrite_union_with(spec, local_class:, fallback: :split)
|
|
292
|
+
case spec
|
|
293
|
+
when Hash
|
|
294
|
+
from_logical = read_string(spec, "from") || read_string(spec, "coll")
|
|
295
|
+
from_collection = canonical_collection_name(from_logical)
|
|
296
|
+
Parse::PipelineSecurity.assert_collection_allowed!(from_collection)
|
|
297
|
+
target_class = resolve_class(from_logical) || resolve_class(from_collection)
|
|
298
|
+
out = spec.dup
|
|
299
|
+
# Either form (from: or coll:) is valid for $unionWith; rename if it's a system class.
|
|
300
|
+
rename_collection_in_place!(out, from_logical, from_collection)
|
|
301
|
+
if has_key?(spec, "pipeline") && target_class
|
|
302
|
+
out[match_original_key(spec, "pipeline")] = rewrite(read_value(spec, "pipeline"), local_class: target_class, fallback: fallback)
|
|
303
|
+
end
|
|
304
|
+
out
|
|
305
|
+
when String
|
|
306
|
+
canonical = canonical_collection_name(spec) || spec
|
|
307
|
+
Parse::PipelineSecurity.assert_collection_allowed!(canonical)
|
|
308
|
+
canonical
|
|
309
|
+
else
|
|
310
|
+
spec
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def rewrite_facet(spec, local_class:, fallback: :split)
|
|
315
|
+
return spec unless spec.is_a?(Hash)
|
|
316
|
+
spec.each_with_object({}) do |(key, sub_pipeline), out|
|
|
317
|
+
out[key] = rewrite(sub_pipeline, local_class: local_class, fallback: fallback)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def rewrite_let_pipeline_form(spec, from_logical, from_collection, target_class, fallback = :split)
|
|
322
|
+
out = spec.dup
|
|
323
|
+
rename_collection_in_place!(out, from_logical, from_collection)
|
|
324
|
+
if has_key?(spec, "pipeline") && target_class
|
|
325
|
+
out[match_original_key(spec, "pipeline")] = rewrite(read_value(spec, "pipeline"), local_class: target_class, fallback: fallback)
|
|
326
|
+
end
|
|
327
|
+
out
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------
|
|
331
|
+
# Class / field resolution
|
|
332
|
+
# ---------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def resolve_class(name)
|
|
335
|
+
return nil if name.nil? || name.to_s.empty?
|
|
336
|
+
# `find_class` already accepts both alias and canonical forms (`User`
|
|
337
|
+
# and `_User` both resolve `Parse::User`) via its `parse_class == "_#{str}"`
|
|
338
|
+
# branch, so the SYSTEM_CLASS_MAP rename here is redundant on the
|
|
339
|
+
# input side -- it's still applied separately to the rewritten `from:`
|
|
340
|
+
# value via `rename_collection_in_place!`.
|
|
341
|
+
Parse::Model.find_class(name.to_s)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def canonical_collection_name(name)
|
|
345
|
+
return nil if name.nil?
|
|
346
|
+
SYSTEM_CLASS_MAP[name.to_s] || name.to_s
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Returns the matching pointer field SYMBOL on the local class for the
|
|
350
|
+
# given logical local-field name, or nil.
|
|
351
|
+
def resolve_forward_pointer(local_class, local_field)
|
|
352
|
+
return nil unless local_class && local_class.respond_to?(:references)
|
|
353
|
+
sym = local_field.to_sym
|
|
354
|
+
return sym if local_class.references.key?(sym)
|
|
355
|
+
camel = local_field.to_s.camelize(:lower).to_sym
|
|
356
|
+
return camel if local_class.references.key?(camel)
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Returns the matching pointer field SYMBOL on the FOREIGN class for the
|
|
361
|
+
# given foreign-field name when that pointer points back at local_class,
|
|
362
|
+
# or nil.
|
|
363
|
+
def resolve_reverse_pointer(target_class, foreign_field, local_class)
|
|
364
|
+
return nil unless target_class && target_class.respond_to?(:references)
|
|
365
|
+
return nil unless local_class.respond_to?(:parse_class)
|
|
366
|
+
candidates = [foreign_field.to_sym, foreign_field.to_s.camelize(:lower).to_sym].uniq
|
|
367
|
+
candidates.each do |sym|
|
|
368
|
+
klass_name = target_class.references[sym]
|
|
369
|
+
return sym if klass_name && klass_name == local_class.parse_class
|
|
370
|
+
end
|
|
371
|
+
nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def foreign_has_parse_reference?(klass)
|
|
375
|
+
klass.respond_to?(:_parse_reference_fields) && Array(klass._parse_reference_fields).any?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------
|
|
379
|
+
# Hash key utilities -- preserve original string-vs-symbol key style
|
|
380
|
+
# ---------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def read_string(spec, name)
|
|
383
|
+
v = read_value(spec, name)
|
|
384
|
+
v.nil? ? nil : v.to_s
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def read_value(spec, name)
|
|
388
|
+
return spec[name] if spec.key?(name)
|
|
389
|
+
sym = name.to_sym
|
|
390
|
+
return spec[sym] if spec.key?(sym)
|
|
391
|
+
str = name.to_s
|
|
392
|
+
return spec[str] if spec.key?(str)
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def has_key?(spec, name)
|
|
397
|
+
spec.key?(name) || spec.key?(name.to_sym) || spec.key?(name.to_s)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Find the actual key object (String or Symbol) the spec uses for `name`,
|
|
401
|
+
# so we can write back without changing the caller's key style. Returns
|
|
402
|
+
# `name` itself if not present.
|
|
403
|
+
def match_original_key(spec, name)
|
|
404
|
+
[name, name.to_sym, name.to_s].each { |k| return k if spec.key?(k) }
|
|
405
|
+
name
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def replace_keys(spec, replacements)
|
|
409
|
+
out = spec.dup
|
|
410
|
+
replacements.each do |name, value|
|
|
411
|
+
key = match_original_key(spec, name)
|
|
412
|
+
out.delete(name)
|
|
413
|
+
out.delete(name.to_sym)
|
|
414
|
+
out.delete(name.to_s)
|
|
415
|
+
out[key] = value
|
|
416
|
+
end
|
|
417
|
+
out
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def drop_keys(spec, names)
|
|
421
|
+
out = spec.dup
|
|
422
|
+
names.each do |name|
|
|
423
|
+
out.delete(name)
|
|
424
|
+
out.delete(name.to_sym)
|
|
425
|
+
out.delete(name.to_s)
|
|
426
|
+
end
|
|
427
|
+
out
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def rename_collection_in_place!(out, from_logical, from_collection)
|
|
431
|
+
return if from_logical.nil? || from_collection.nil? || from_collection == from_logical
|
|
432
|
+
# Match either `from:` or `coll:` style key, preserving its string/symbol form.
|
|
433
|
+
%w[from coll].each do |k|
|
|
434
|
+
next unless out.key?(k) || out.key?(k.to_sym)
|
|
435
|
+
key = match_original_key(out, k)
|
|
436
|
+
out[key] = from_collection
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def rename_collection_only(spec, from_logical, from_collection)
|
|
441
|
+
return spec unless from_logical && from_collection && from_collection != from_logical
|
|
442
|
+
replace_keys(spec, "from" => from_collection)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|