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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. 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