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,3279 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "constraint"
5
+
6
+ # Each constraint type is a subclass of Parse::Constraint
7
+ # We register each keyword (which is the Parse query operator)
8
+ # and the local operator we want to use. Each of the registered local
9
+ # operators are added as methods to the Symbol class.
10
+ # For more information: https://parse.com/docs/rest/guide#queries
11
+ # For more information about the query design pattern from DataMapper
12
+ # that inspired this, see http://datamapper.org/docs/find.html
13
+
14
+ module Parse
15
+ # Security module for validating regex patterns to prevent ReDoS attacks.
16
+ # MongoDB uses PCRE which is susceptible to catastrophic backtracking.
17
+ module RegexSecurity
18
+ # Maximum allowed length for regex patterns
19
+ MAX_PATTERN_LENGTH = 500
20
+
21
+ # Patterns that can cause exponential backtracking in PCRE
22
+ DANGEROUS_PATTERNS = [
23
+ /\(\?\=|\(\?\!|\(\?\<[!=]/, # Lookahead/lookbehind assertions
24
+ /\{(\d{3,}|\d+,\d{3,})\}/, # Large repetition counts {1000} or {1,1000}
25
+ /(\.\*|\.\+)\s*(\.\*|\.\+)/, # Consecutive .* or .+ patterns
26
+ /\([^)]*(\+|\*)[^)]*\)\s*(\+|\*)/, # Nested quantifiers like (a+)+
27
+ /\(\?[^)]*\([^)]*(\+|\*)[^)]*\)[^)]*(\+|\*)\)/, # More complex nested quantifiers
28
+ ].freeze
29
+
30
+ class << self
31
+ # Validates a regex pattern for potential ReDoS vulnerabilities.
32
+ # @param pattern [String, Regexp] the pattern to validate
33
+ # @param max_length [Integer] maximum allowed pattern length
34
+ # @raise [ArgumentError] if the pattern is potentially dangerous
35
+ # @return [String] the validated pattern string
36
+ def validate!(pattern, max_length: MAX_PATTERN_LENGTH)
37
+ pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
38
+
39
+ if pattern_str.length > max_length
40
+ raise ArgumentError, "Regex pattern too long (#{pattern_str.length} chars, max #{max_length}). " \
41
+ "Long patterns can cause performance issues."
42
+ end
43
+
44
+ DANGEROUS_PATTERNS.each do |dangerous|
45
+ if pattern_str.match?(dangerous)
46
+ raise ArgumentError, "Regex pattern contains potentially dangerous constructs that could cause " \
47
+ "ReDoS (Regular Expression Denial of Service). Pattern: #{pattern_str.inspect}"
48
+ end
49
+ end
50
+
51
+ pattern_str
52
+ end
53
+
54
+ # Checks if a pattern is safe without raising an exception.
55
+ # @param pattern [String, Regexp] the pattern to check
56
+ # @return [Boolean] true if safe, false if potentially dangerous
57
+ def safe?(pattern, max_length: MAX_PATTERN_LENGTH)
58
+ validate!(pattern, max_length: max_length)
59
+ true
60
+ rescue ArgumentError
61
+ false
62
+ end
63
+ end
64
+ end
65
+
66
+ class Constraint
67
+ # A constraint for matching by a specific objectId value.
68
+ #
69
+ # # where this Parse object equals the object in the column `field`.
70
+ # q.where :field => Parse::Pointer("Field", "someObjectId")
71
+ # # alias, shorthand when we infer `:field` maps to `Field` parse class.
72
+ # q.where :field.id => "someObjectId"
73
+ # # "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
74
+ #
75
+ # class Artist < Parse::Object
76
+ # end
77
+ #
78
+ # class Song < Parse::Object
79
+ # belongs_to :artist
80
+ # end
81
+ #
82
+ # artist = Artist.first # get any artist
83
+ # artist_id = artist.id # ex. artist.id
84
+ #
85
+ # # find all songs for this artist object
86
+ # Song.all :artist => artist
87
+ #
88
+ # In some cases, you do not have the Parse object, but you have its `objectId`.
89
+ # You can use the objectId in the query as follows:
90
+ #
91
+ # # shorthand if you are using convention. Will infer class `Artist`
92
+ # Song.all :artist.id => artist_id
93
+ #
94
+ # # other approaches, same result
95
+ # Song.all :artist.id => artist # safely supported Parse::Pointer
96
+ # Song.all :artist => Artist.pointer(artist_id)
97
+ # Song.all :artist => Parse::Pointer.new("Artist", artist_id)
98
+ #
99
+ class ObjectIdConstraint < Constraint
100
+ # @!method id
101
+ # A registered method on a symbol to create the constraint.
102
+ # @example
103
+ # q.where :field.id => "someObjectId"
104
+ # q.where :field.id => pointer # safely supported
105
+ # @return [ObjectIdConstraint]
106
+ register :id
107
+
108
+ # @return [Hash] the compiled constraint.
109
+ def build
110
+ className = operand.to_parse_class
111
+ value = formatted_value
112
+ # if it is already a pointer value, just return the constraint. Allows for
113
+ # supporting strings, symbols and pointers.
114
+ return { @operation.operand => value } if value.is_a?(Parse::Pointer)
115
+
116
+ begin
117
+ klass = className.constantize
118
+ rescue NameError
119
+ klass = Parse::Model.find_class className
120
+ end
121
+
122
+ unless klass.present? && klass.is_a?(Parse::Object) == false
123
+ raise ArgumentError, "#{self.class}: No Parse class defined for #{operand} as '#{className}'"
124
+ end
125
+
126
+ # allow symbols
127
+ value = value.to_s if value.is_a?(Symbol)
128
+
129
+ unless value.is_a?(String) && value.strip.present?
130
+ raise ArgumentError, "#{self.class}: value must be of string type representing a Parse object id."
131
+ end
132
+ value.strip!
133
+ return { @operation.operand => klass.pointer(value) }
134
+ end
135
+ end
136
+
137
+ # Equivalent to the `$or` Parse query operation. This is useful if you want to
138
+ # find objects that match several queries. We overload the `|` operator in
139
+ # order to have a clean syntax for joining these `or` operations.
140
+ # or_query = query1 | query2 | query3
141
+ # query = Player.where(:wins.gt => 150) | Player.where(:wins.lt => 5)
142
+ #
143
+ # query.or_where :field => value
144
+ #
145
+ class CompoundQueryConstraint < Constraint
146
+ constraint_keyword :$or
147
+ register :or
148
+
149
+ # @return [Hash] the compiled constraint.
150
+ def build
151
+ or_clauses = formatted_value
152
+ return { :$or => Array.wrap(or_clauses) }
153
+ end
154
+ end
155
+
156
+ # Equivalent to the `$lte` Parse query operation. The alias `on_or_before` is provided for readability.
157
+ # q.where :field.lte => value
158
+ # q.where :field.on_or_before => date
159
+ #
160
+ # q.where :created_at.on_or_before => DateTime.now
161
+ # @see LessThanConstraint
162
+ class LessThanOrEqualConstraint < Constraint
163
+ # @!method lte
164
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$lte".
165
+ # @example
166
+ # q.where :field.lte => value
167
+ # @return [LessThanOrEqualConstraint]
168
+
169
+ # @!method less_than_or_equal
170
+ # Alias for {lte}
171
+ # @return [LessThanOrEqualConstraint]
172
+
173
+ # @!method on_or_before
174
+ # Alias for {lte} that provides better readability when constraining dates.
175
+ # @return [LessThanOrEqualConstraint]
176
+ constraint_keyword :$lte
177
+ register :lte
178
+ register :less_than_or_equal
179
+ register :on_or_before
180
+ end
181
+
182
+ # Equivalent to the `$lt` Parse query operation. The alias `before` is provided for readability.
183
+ # q.where :field.lt => value
184
+ # q.where :field.before => date
185
+ #
186
+ # q.where :created_at.before => DateTime.now
187
+ class LessThanConstraint < Constraint
188
+ # @!method lt
189
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$lt".
190
+ # @example
191
+ # q.where :field.lt => value
192
+ # @return [LessThanConstraint]
193
+
194
+ # @!method less_than
195
+ # # Alias for {lt}.
196
+ # @return [LessThanConstraint]
197
+
198
+ # @!method before
199
+ # Alias for {lt} that provides better readability when constraining dates.
200
+ # @return [LessThanConstraint]
201
+ constraint_keyword :$lt
202
+ register :lt
203
+ register :less_than
204
+ register :before
205
+ end
206
+
207
+ # Equivalent to the `$gt` Parse query operation. The alias `after` is provided for readability.
208
+ # q.where :field.gt => value
209
+ # q.where :field.after => date
210
+ #
211
+ # q.where :created_at.after => DateTime.now
212
+ # @see GreaterThanOrEqualConstraint
213
+ class GreaterThanConstraint < Constraint
214
+ # @!method gt
215
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$gt".
216
+ # @example
217
+ # q.where :field.gt => value
218
+ # @return [GreaterThanConstraint]
219
+
220
+ # @!method greater_than
221
+ # # Alias for {gt}.
222
+ # @return [GreaterThanConstraint]
223
+
224
+ # @!method after
225
+ # Alias for {gt} that provides better readability when constraining dates.
226
+ # @return [GreaterThanConstraint]
227
+ constraint_keyword :$gt
228
+ register :gt
229
+ register :greater_than
230
+ register :after
231
+ end
232
+
233
+ # Equivalent to the `$gte` Parse query operation. The alias `on_or_after` is provided for readability.
234
+ # q.where :field.gte => value
235
+ # q.where :field.on_or_after => date
236
+ #
237
+ # q.where :created_at.on_or_after => DateTime.now
238
+ # @see GreaterThanConstraint
239
+ class GreaterThanOrEqualConstraint < Constraint
240
+ # @!method gte
241
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$gte".
242
+ # @example
243
+ # q.where :field.gte => value
244
+ # @return [GreaterThanOrEqualConstraint]
245
+
246
+ # @!method greater_than_or_equal
247
+ # # Alias for {gte}.
248
+ # @return [GreaterThanOrEqualConstraint]
249
+
250
+ # @!method on_or_after
251
+ # Alias for {gte} that provides better readability when constraining dates.
252
+ # @return [GreaterThanOrEqualConstraint]
253
+ constraint_keyword :$gte
254
+ register :gte
255
+ register :greater_than_or_equal
256
+ register :on_or_after
257
+ end
258
+
259
+ # Equivalent to the `$ne` Parse query operation. Where a particular field is not equal to value.
260
+ # q.where :field.not => value
261
+ class NotEqualConstraint < Constraint
262
+ # @!method not
263
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$ne".
264
+ # @example
265
+ # q.where :field.not => value
266
+ # @return [NotEqualConstraint]
267
+
268
+ # @!method ne
269
+ # # Alias for {not}.
270
+ # @return [NotEqualConstraint]
271
+ constraint_keyword :$ne
272
+ register :not
273
+ register :ne
274
+ end
275
+
276
+ # Provides a mechanism using the equality operator to check for `(undefined)` values.
277
+ # Nullabiliity constraint maps the `$exists` Parse clause to enable checking for
278
+ # existance in a column when performing geoqueries due to a Parse limitation.
279
+ # q.where :field.null => false
280
+ # @note Parse currently has a bug that if you select items near a location
281
+ # and want to make sure a different column has a value, you need to
282
+ # search where the column does not contanin a null/undefined value.
283
+ # Therefore we override the build method to change the operation to a
284
+ # {NotEqualConstraint}.
285
+ # @see ExistsConstraint
286
+ class NullabilityConstraint < Constraint
287
+ # @!method null
288
+ # A registered method on a symbol to create the constraint.
289
+ # @example
290
+ # q.where :field.null => true
291
+ # @return [NullabilityConstraint]
292
+ constraint_keyword :$exists
293
+ register :null
294
+
295
+ # @return [Hash] the compiled constraint.
296
+ def build
297
+ # if nullability is equal true, then $exists should be set to false
298
+
299
+ value = formatted_value
300
+ unless value == true || value == false
301
+ raise ArgumentError, "#{self.class}: Non-Boolean value passed, it must be either `true` or `false`"
302
+ end
303
+
304
+ if value == true
305
+ return { @operation.operand => { key => false } }
306
+ else
307
+ #current bug in parse where if you want exists => true with geo queries
308
+ # we should map it to a "not equal to null" constraint
309
+ return { @operation.operand => { Parse::Constraint::NotEqualConstraint.key => nil } }
310
+ end
311
+ end
312
+ end
313
+
314
+ # Equivalent to the `#exists` Parse query operation. Checks whether a value is
315
+ # set for key. The difference between this operation and the nullability check
316
+ # is when using compound queries with location.
317
+ # q.where :field.exists => true
318
+ #
319
+ # @see NullabilityConstraint
320
+ class ExistsConstraint < Constraint
321
+ # @!method exists
322
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$exists".
323
+ # @example
324
+ # q.where :field.exists => true
325
+ # @return [ExistsConstraint]
326
+ constraint_keyword :$exists
327
+ register :exists
328
+
329
+ # @return [Hash] the compiled constraint.
330
+ def build
331
+ # if nullability is equal true, then $exists should be set to false
332
+ value = formatted_value
333
+
334
+ unless value == true || value == false
335
+ raise ArgumentError, "#{self.class}: Non-Boolean value passed, it must be either `true` or `false`"
336
+ end
337
+
338
+ return { @operation.operand => { key => value } }
339
+ end
340
+ end
341
+
342
+ # Checks whether an array field contains any elements
343
+ # q.where :field.empty => true
344
+ #
345
+ # @see NullabilityConstraint
346
+ class EmptyConstraint < Constraint
347
+ # @!method empty
348
+ # A registered method on a symbol to create the constraint.
349
+ # @example
350
+ # q.where :field.empty => true
351
+ # @return [ExistsConstraint]
352
+ constraint_keyword :$exists
353
+ register :empty
354
+
355
+ # @return [Hash] the compiled constraint.
356
+ def build
357
+ # if nullability is equal true, then $empty should be set to false
358
+ value = formatted_value
359
+
360
+ unless value == true || value == false
361
+ raise ArgumentError, "#{self.class}: Non-Boolean value passed, it must be either `true` or `false`"
362
+ end
363
+
364
+ return { "#{@operation.operand}.0" => { key => !value } }
365
+ end
366
+ end
367
+
368
+ # Equivalent to the `$in` Parse query operation. Checks whether the value in the
369
+ # column field is contained in the set of values in the target array. If the
370
+ # field is an array data type, it checks whether at least one value in the
371
+ # field array is contained in the set of values in the target array.
372
+ # q.where :field.in => array
373
+ # q.where :score.in => [1,3,5,7,9]
374
+ #
375
+ # @see ContainsAllConstraint
376
+ # @see NotContainedInConstraint
377
+ class ContainedInConstraint < Constraint
378
+ # @!method in
379
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$in".
380
+ # @example
381
+ # q.where :field.in => array
382
+ # @return [ContainedInConstraint]
383
+
384
+ # @!method contained_in
385
+ # Alias for {in}
386
+ # @return [ContainedInConstraint]
387
+ # @!method any
388
+ # Alias for {in} - more readable when checking if array contains any of the values
389
+ # @example
390
+ # q.where :tags.any => ["rock", "pop"] # has at least one of these tags
391
+ # @return [ContainedInConstraint]
392
+ constraint_keyword :$in
393
+ register :in
394
+ register :contained_in
395
+ register :any
396
+
397
+ # @return [Hash] the compiled constraint.
398
+ def build
399
+ val = formatted_value
400
+ val = [val].compact unless val.is_a?(Array)
401
+
402
+ # Convert Parse objects to pointers for array contains queries
403
+ if val.is_a?(Array)
404
+ val = val.map do |item|
405
+ item.respond_to?(:pointer) ? item.pointer : item
406
+ end
407
+ end
408
+
409
+ { @operation.operand => { key => val } }
410
+ end
411
+ end
412
+
413
+ # Equivalent to the `$nin` Parse query operation. Checks whether the value in
414
+ # the column field is *not* contained in the set of values in the target
415
+ # array. If the field is an array data type, it checks whether at least one
416
+ # value in the field array is *not* contained in the set of values in the
417
+ # target array.
418
+ #
419
+ # q.where :field.not_in => array
420
+ # q.where :player_name.not_in => ["Jonathan", "Dario", "Shawn"]
421
+ # @see ContainedInConstraint
422
+ # @see ContainsAllConstraint
423
+ class NotContainedInConstraint < Constraint
424
+ # @!method not_in
425
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$nin".
426
+ # @example
427
+ # q.where :field.not_in => array
428
+ # @return [NotContainedInConstraint]
429
+
430
+ # @!method nin
431
+ # Alias for {not_in}
432
+ # @return [NotContainedInConstraint]
433
+
434
+ # @!method not_contained_in
435
+ # Alias for {not_in}
436
+ # @return [NotContainedInConstraint]
437
+ # @!method none
438
+ # Alias for {not_in} - more readable when checking if array contains none of the values
439
+ # @example
440
+ # q.where :tags.none => ["rock", "pop"] # has none of these tags
441
+ # @return [NotContainedInConstraint]
442
+ constraint_keyword :$nin
443
+ register :not_in
444
+ register :nin
445
+ register :not_contained_in
446
+ register :none
447
+
448
+ # @return [Hash] the compiled constraint.
449
+ def build
450
+ val = formatted_value
451
+ val = [val].compact unless val.is_a?(Array)
452
+
453
+ # Convert Parse objects to pointers for array contains queries
454
+ if val.is_a?(Array)
455
+ val = val.map do |item|
456
+ item.respond_to?(:pointer) ? item.pointer : item
457
+ end
458
+ end
459
+
460
+ { @operation.operand => { key => val } }
461
+ end
462
+ end
463
+
464
+ # Equivalent to the $all Parse query operation. Checks whether the value in
465
+ # the column field contains all of the given values provided in the array. Note
466
+ # that the field column should be of type {Array} in your Parse class.
467
+ #
468
+ # q.where :field.all => array
469
+ # q.where :array_key.all => [2,3,4]
470
+ #
471
+ # @see ContainedInConstraint
472
+ # @see NotContainedInConstraint
473
+ class ContainsAllConstraint < Constraint
474
+ # @!method all
475
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$all".
476
+ # @example
477
+ # q.where :field.all => array
478
+ # @return [ContainsAllConstraint]
479
+
480
+ # @!method contains_all
481
+ # Alias for {all}
482
+ # @return [ContainsAllConstraint]
483
+
484
+ # @!method superset_of
485
+ # Alias for {all} - semantically clearer when checking if array is a superset
486
+ # @example
487
+ # q.where :tags.superset_of => ["rock"] # contains at least "rock" (and possibly more)
488
+ # @return [ContainsAllConstraint]
489
+ constraint_keyword :$all
490
+ register :all
491
+ register :contains_all
492
+ register :superset_of
493
+
494
+ # @return [Hash] the compiled constraint.
495
+ def build
496
+ val = formatted_value
497
+ val = [val].compact unless val.is_a?(Array)
498
+ { @operation.operand => { key => val } }
499
+ end
500
+ end
501
+
502
+ # Array size constraint using MongoDB aggregation.
503
+ # Parse Server does not natively support $size query constraint, so we use
504
+ # MongoDB aggregation pipeline with $expr and $size to check array length.
505
+ #
506
+ # # Exact size match
507
+ # q.where :field.size => 2
508
+ # q.where :tags.size => 5
509
+ #
510
+ # # Comparison operators via hash
511
+ # q.where :tags.size => { gt: 3 } # size > 3
512
+ # q.where :tags.size => { gte: 2 } # size >= 2
513
+ # q.where :tags.size => { lt: 5 } # size < 5
514
+ # q.where :tags.size => { lte: 4 } # size <= 4
515
+ # q.where :tags.size => { ne: 0 } # size != 0
516
+ #
517
+ # # Combine for range
518
+ # q.where :tags.size => { gte: 2, lt: 10 } # 2 <= size < 10
519
+ #
520
+ # @note This constraint uses aggregation pipeline because Parse Server
521
+ # does not support the $size query operator natively.
522
+ #
523
+ # @note This constraint uses MongoDB aggregation pipeline. While $expr expressions
524
+ # cannot utilize field indexes, aggregation is efficient for array size operations
525
+ # that would otherwise require client-side filtering.
526
+ #
527
+ # @see ContainsAllConstraint
528
+ # @see ArraySetEqualsConstraint
529
+ class ArraySizeConstraint < Constraint
530
+ # @!method size
531
+ # A registered method on a symbol to create the constraint.
532
+ # @example
533
+ # q.where :field.size => 2
534
+ # q.where :field.size => { gt: 3, lte: 10 }
535
+ # @return [ArraySizeConstraint]
536
+ register :size
537
+
538
+ # Mapping of constraint keys to MongoDB comparison operators
539
+ COMPARISON_OPERATORS = {
540
+ gt: "$gt",
541
+ gte: "$gte",
542
+ lt: "$lt",
543
+ lte: "$lte",
544
+ ne: "$ne",
545
+ eq: "$eq",
546
+ }.freeze
547
+
548
+ # @return [Hash] the compiled constraint using aggregation pipeline.
549
+ def build
550
+ value = formatted_value
551
+ field_name = Parse::Query.format_field(@operation.operand)
552
+ size_expr = { "$size" => { "$ifNull" => ["$#{field_name}", []] } }
553
+
554
+ if value.is_a?(Integer)
555
+ # Simple exact match
556
+ raise ArgumentError, "#{self.class}: Size value must be non-negative" if value < 0
557
+
558
+ pipeline = [
559
+ {
560
+ "$match" => {
561
+ "$expr" => {
562
+ "$eq" => [size_expr, value],
563
+ },
564
+ },
565
+ },
566
+ ]
567
+ elsif value.is_a?(Hash)
568
+ # Hash with comparison operators
569
+ conditions = []
570
+
571
+ value.each do |op, val|
572
+ op_sym = op.to_sym
573
+ unless COMPARISON_OPERATORS.key?(op_sym)
574
+ raise ArgumentError, "#{self.class}: Unknown operator '#{op}'. Valid operators: #{COMPARISON_OPERATORS.keys.join(", ")}"
575
+ end
576
+ unless val.is_a?(Integer) && val >= 0
577
+ raise ArgumentError, "#{self.class}: Value for '#{op}' must be a non-negative integer"
578
+ end
579
+
580
+ mongo_op = COMPARISON_OPERATORS[op_sym]
581
+ conditions << { mongo_op => [size_expr, val] }
582
+ end
583
+
584
+ # Combine multiple conditions with $and
585
+ expr = conditions.length == 1 ? conditions.first : { "$and" => conditions }
586
+
587
+ pipeline = [
588
+ {
589
+ "$match" => {
590
+ "$expr" => expr,
591
+ },
592
+ },
593
+ ]
594
+ else
595
+ raise ArgumentError, "#{self.class}: Value must be an integer or hash with comparison operators (gt, gte, lt, lte, ne, eq)"
596
+ end
597
+
598
+ { "__aggregation_pipeline" => pipeline }
599
+ end
600
+ end
601
+
602
+ # Array empty constraint - matches arrays with no elements.
603
+ # Uses direct equality for the true case (index-friendly) rather than $size.
604
+ #
605
+ # q.where :tags.arr_empty => true # arrays with 0 elements (uses { field: [] })
606
+ # q.where :tags.arr_empty => false # arrays with 1+ elements (uses $size > 0)
607
+ #
608
+ # @note This uses the arr_empty name to avoid conflict with the existing empty constraint
609
+ # which checks if the first array element exists.
610
+ # @note The true case uses equality which can leverage MongoDB indexes.
611
+ # The false case still requires $size which cannot use indexes.
612
+ #
613
+ # @see ArraySizeConstraint
614
+ # @see ArrayNotEmptyConstraint
615
+ # @see ArrayEmptyOrNilConstraint
616
+ class ArrayEmptyConstraint < Constraint
617
+ # @!method arr_empty
618
+ # A registered method on a symbol to create the constraint.
619
+ # @example
620
+ # q.where :field.arr_empty => true
621
+ # @return [ArrayEmptyConstraint]
622
+ register :arr_empty
623
+
624
+ # @return [Hash] the compiled constraint using aggregation pipeline.
625
+ def build
626
+ value = formatted_value
627
+ unless value == true || value == false
628
+ raise ArgumentError, "#{self.class}: Value must be true or false"
629
+ end
630
+
631
+ field_name = Parse::Query.format_field(@operation.operand)
632
+
633
+ if value
634
+ # Use direct equality for empty array (can use MongoDB index)
635
+ pipeline = [{ "$match" => { field_name => [] } }]
636
+ else
637
+ # For non-empty, use $ne [] which is index-friendly
638
+ # Note: This matches arrays with elements but also matches nil/missing
639
+ # Use not_empty constraint if you need to exclude nil/missing
640
+ pipeline = [{ "$match" => { field_name => { "$ne" => [] } } }]
641
+ end
642
+
643
+ { "__aggregation_pipeline" => pipeline }
644
+ end
645
+ end
646
+
647
+ # Array not-empty constraint - shorthand for size > 0.
648
+ # Matches arrays that have at least one element.
649
+ #
650
+ # q.where :tags.arr_nempty => true # arrays with 1+ elements
651
+ # q.where :tags.arr_nempty => false # arrays with 0 elements (same as empty)
652
+ #
653
+ # @see ArraySizeConstraint
654
+ # @see ArrayEmptyConstraint
655
+ class ArrayNotEmptyConstraint < Constraint
656
+ # @!method arr_nempty
657
+ # A registered method on a symbol to create the constraint.
658
+ # @example
659
+ # q.where :field.arr_nempty => true
660
+ # @return [ArrayNotEmptyConstraint]
661
+ register :arr_nempty
662
+
663
+ # @return [Hash] the compiled constraint using aggregation pipeline.
664
+ def build
665
+ value = formatted_value
666
+ unless value == true || value == false
667
+ raise ArgumentError, "#{self.class}: Value must be true or false"
668
+ end
669
+
670
+ field_name = Parse::Query.format_field(@operation.operand)
671
+ size_expr = { "$size" => { "$ifNull" => ["$#{field_name}", []] } }
672
+
673
+ # If true, match size > 0; if false, match size == 0
674
+ comparison = value ? { "$gt" => [size_expr, 0] } : { "$eq" => [size_expr, 0] }
675
+
676
+ pipeline = [
677
+ {
678
+ "$match" => {
679
+ "$expr" => comparison,
680
+ },
681
+ },
682
+ ]
683
+
684
+ { "__aggregation_pipeline" => pipeline }
685
+ end
686
+ end
687
+
688
+ # Array empty or nil constraint - matches arrays that are empty OR nil/missing.
689
+ # This is useful for finding records where an array field has no values,
690
+ # whether it's explicitly empty or was never set.
691
+ #
692
+ # q.where :tags.empty_or_nil => true # matches [] or nil/missing
693
+ # q.where :tags.empty_or_nil => false # matches non-empty arrays only
694
+ #
695
+ # @note Uses index-friendly operations where possible.
696
+ #
697
+ # @see ArrayEmptyConstraint
698
+ # @see ExistsConstraint
699
+ class ArrayEmptyOrNilConstraint < Constraint
700
+ # @!method empty_or_nil
701
+ # A registered method on a symbol to create the constraint.
702
+ # @example
703
+ # q.where :field.empty_or_nil => true
704
+ # @return [ArrayEmptyOrNilConstraint]
705
+ register :empty_or_nil
706
+
707
+ # @return [Hash] the compiled constraint using aggregation pipeline.
708
+ def build
709
+ value = formatted_value
710
+ unless value == true || value == false
711
+ raise ArgumentError, "#{self.class}: Value must be true or false"
712
+ end
713
+
714
+ # Use formatted field name for proper Parse field mapping
715
+ field_name = Parse::Query.format_field(@operation.operand)
716
+
717
+ if value
718
+ # Match empty array OR nil/missing field
719
+ # Use explicit $eq for empty array check (more reliable through Parse Server)
720
+ pipeline = [
721
+ {
722
+ "$match" => {
723
+ "$or" => [
724
+ { field_name => { "$exists" => true, "$eq" => [] } },
725
+ { field_name => { "$exists" => false } },
726
+ { field_name => { "$eq" => nil } },
727
+ ],
728
+ },
729
+ },
730
+ ]
731
+ else
732
+ # Match non-empty arrays (must exist, not nil, and not empty)
733
+ # Use $and to combine multiple conditions without duplicate keys
734
+ pipeline = [
735
+ {
736
+ "$match" => {
737
+ "$and" => [
738
+ { field_name => { "$exists" => true } },
739
+ { field_name => { "$ne" => nil } },
740
+ { field_name => { "$ne" => [] } },
741
+ ],
742
+ },
743
+ },
744
+ ]
745
+ end
746
+
747
+ { "__aggregation_pipeline" => pipeline }
748
+ end
749
+ end
750
+
751
+ # Array not empty constraint - matches arrays that have at least one element.
752
+ # This is the opposite of empty_or_nil: it matches only non-empty arrays.
753
+ #
754
+ # q.where :tags.not_empty => true # matches non-empty arrays only
755
+ # q.where :tags.not_empty => false # matches [] or nil/missing
756
+ #
757
+ # @note Uses index-friendly operations where possible.
758
+ #
759
+ # @see ArrayEmptyOrNilConstraint
760
+ # @see ArrayEmptyConstraint
761
+ class ArrayNotEmptyOrNilConstraint < Constraint
762
+ # @!method not_empty
763
+ # A registered method on a symbol to create the constraint.
764
+ # @example
765
+ # q.where :field.not_empty => true
766
+ # @return [ArrayNotEmptyOrNilConstraint]
767
+ register :not_empty
768
+
769
+ # @return [Hash] the compiled constraint using aggregation pipeline.
770
+ def build
771
+ value = formatted_value
772
+ unless value == true || value == false
773
+ raise ArgumentError, "#{self.class}: Value must be true or false"
774
+ end
775
+
776
+ # Use formatted field name for proper Parse field mapping
777
+ field_name = Parse::Query.format_field(@operation.operand)
778
+
779
+ if value
780
+ # Match non-empty arrays (must exist, not nil, and not empty)
781
+ # Use $and to combine multiple conditions without duplicate keys
782
+ pipeline = [
783
+ {
784
+ "$match" => {
785
+ "$and" => [
786
+ { field_name => { "$exists" => true } },
787
+ { field_name => { "$ne" => nil } },
788
+ { field_name => { "$ne" => [] } },
789
+ ],
790
+ },
791
+ },
792
+ ]
793
+ else
794
+ # Match empty array OR nil/missing field
795
+ # Use explicit $eq for empty array check (more reliable through Parse Server)
796
+ pipeline = [
797
+ {
798
+ "$match" => {
799
+ "$or" => [
800
+ { field_name => { "$exists" => true, "$eq" => [] } },
801
+ { field_name => { "$exists" => false } },
802
+ { field_name => { "$eq" => nil } },
803
+ ],
804
+ },
805
+ },
806
+ ]
807
+ end
808
+
809
+ { "__aggregation_pipeline" => pipeline }
810
+ end
811
+ end
812
+
813
+ # Set equality constraint using MongoDB aggregation with $setEquals.
814
+ # Matches arrays that contain exactly the same elements, regardless of order.
815
+ # This is order-independent matching: [A, B] matches [B, A] but not [A, B, C].
816
+ #
817
+ # q.where :field.set_equals => ["rock", "pop"]
818
+ # q.where :tags.set_equals => [category1, category2] # for pointers
819
+ #
820
+ # For pointer arrays (has_many relations), pass Parse objects or pointers.
821
+ # The constraint will automatically extract objectIds for comparison.
822
+ #
823
+ # @note This constraint uses aggregation pipeline with MongoDB $setEquals.
824
+ #
825
+ # @see ContainsAllConstraint
826
+ # @see ArrayEqConstraint
827
+ class ArraySetEqualsConstraint < Constraint
828
+ # @!method set_equals
829
+ # A registered method on a symbol to create the constraint.
830
+ # @example
831
+ # q.where :field.set_equals => ["value1", "value2"]
832
+ # q.where :categories.set_equals => [cat1, cat2]
833
+ # @return [ArraySetEqualsConstraint]
834
+ register :set_equals
835
+
836
+ # @return [Hash] the compiled constraint using aggregation pipeline.
837
+ def build
838
+ val = formatted_value
839
+ val = [val].compact unless val.is_a?(Array)
840
+
841
+ field_name = Parse::Query.format_field(@operation.operand)
842
+
843
+ # Check if values are pointers (Parse objects or pointer objects)
844
+ is_pointer_array = val.any? do |item|
845
+ item.respond_to?(:pointer) || item.is_a?(Parse::Pointer)
846
+ end
847
+
848
+ if is_pointer_array
849
+ # Extract objectIds from pointers for comparison
850
+ target_ids = val.map do |item|
851
+ if item.respond_to?(:id)
852
+ item.id
853
+ elsif item.is_a?(Parse::Pointer)
854
+ item.id
855
+ else
856
+ item
857
+ end
858
+ end
859
+
860
+ # Validate all IDs are present (unsaved objects have nil IDs)
861
+ if target_ids.any?(&:nil?)
862
+ raise ArgumentError, "#{self.class.name}: Cannot use unsaved objects (missing ID) in array constraint"
863
+ end
864
+
865
+ # For pointer arrays, we need to map the objectIds from the stored pointers.
866
+ # $ifNull coerces a missing/null field to [] so $map (and $setEquals) don't
867
+ # raise type errors on legacy documents that lack the field.
868
+ pipeline = [
869
+ {
870
+ "$match" => {
871
+ "$expr" => {
872
+ "$setEquals" => [
873
+ { "$map" => {
874
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
875
+ "as" => "p",
876
+ "in" => "$$p.objectId",
877
+ } },
878
+ target_ids,
879
+ ],
880
+ },
881
+ },
882
+ },
883
+ ]
884
+ else
885
+ # For simple value arrays (strings, numbers, etc.).
886
+ # $ifNull coerces a missing/null field to [] so $setEquals doesn't raise
887
+ # a type error on legacy documents that lack the field.
888
+ pipeline = [
889
+ {
890
+ "$match" => {
891
+ "$expr" => {
892
+ "$setEquals" => [
893
+ { "$ifNull" => ["$#{field_name}", []] },
894
+ val,
895
+ ],
896
+ },
897
+ },
898
+ },
899
+ ]
900
+ end
901
+
902
+ { "__aggregation_pipeline" => pipeline }
903
+ end
904
+ end
905
+
906
+ # Exact array equality constraint using MongoDB aggregation with $eq.
907
+ # Matches arrays that are exactly equal, including element order.
908
+ # This is order-dependent matching: [A, B] does NOT match [B, A].
909
+ #
910
+ # q.where :field.eq_array => ["rock", "pop"]
911
+ # q.where :tags.eq_array => [category1, category2] # for pointers
912
+ #
913
+ # For pointer arrays (has_many relations), pass Parse objects or pointers.
914
+ # The constraint will automatically extract objectIds for comparison.
915
+ #
916
+ # @note This constraint uses aggregation pipeline with MongoDB $eq on arrays.
917
+ #
918
+ # @see ContainsAllConstraint
919
+ # @see ArraySetEqualsConstraint
920
+ class ArrayEqConstraint < Constraint
921
+ # @!method eq_array
922
+ # A registered method on a symbol to create the constraint.
923
+ # @example
924
+ # q.where :field.eq_array => ["value1", "value2"]
925
+ # q.where :categories.eq_array => [cat1, cat2]
926
+ # @return [ArrayEqConstraint]
927
+ #
928
+ # @note Use :eq_array for explicit array equality matching.
929
+ # Simple :eq is handled by the base Constraint class for scalar equality.
930
+ register :eq_array
931
+
932
+ # @return [Hash] the compiled constraint using aggregation pipeline.
933
+ def build
934
+ val = formatted_value
935
+ val = [val].compact unless val.is_a?(Array)
936
+
937
+ field_name = Parse::Query.format_field(@operation.operand)
938
+
939
+ # Check if values are pointers (Parse objects or pointer objects)
940
+ is_pointer_array = val.any? do |item|
941
+ item.respond_to?(:pointer) || item.is_a?(Parse::Pointer)
942
+ end
943
+
944
+ if is_pointer_array
945
+ # Extract objectIds from pointers for comparison
946
+ target_ids = val.map do |item|
947
+ if item.respond_to?(:id)
948
+ item.id
949
+ elsif item.is_a?(Parse::Pointer)
950
+ item.id
951
+ else
952
+ item
953
+ end
954
+ end
955
+
956
+ # Validate all IDs are present (unsaved objects have nil IDs)
957
+ if target_ids.any?(&:nil?)
958
+ raise ArgumentError, "#{self.class.name}: Cannot use unsaved objects (missing ID) in array constraint"
959
+ end
960
+
961
+ # For pointer arrays, compare mapped objectIds with exact equality (order matters).
962
+ # $ifNull coerces a missing/null field to [] so $map doesn't raise a type
963
+ # error on legacy documents that lack the field.
964
+ pipeline = [
965
+ {
966
+ "$match" => {
967
+ "$expr" => {
968
+ "$eq" => [
969
+ { "$map" => {
970
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
971
+ "as" => "p",
972
+ "in" => "$$p.objectId",
973
+ } },
974
+ target_ids,
975
+ ],
976
+ },
977
+ },
978
+ },
979
+ ]
980
+ else
981
+ # For simple value arrays, direct $eq comparison (order matters).
982
+ # $ifNull coerces a missing/null field to [] so a doc lacking the field
983
+ # is treated the same as one with [], consistent with set_equals/arr_empty.
984
+ pipeline = [
985
+ {
986
+ "$match" => {
987
+ "$expr" => {
988
+ "$eq" => [
989
+ { "$ifNull" => ["$#{field_name}", []] },
990
+ val,
991
+ ],
992
+ },
993
+ },
994
+ },
995
+ ]
996
+ end
997
+
998
+ { "__aggregation_pipeline" => pipeline }
999
+ end
1000
+ end
1001
+
1002
+ # Array not-equal constraint using MongoDB aggregation with $ne.
1003
+ # Matches arrays that are NOT exactly equal (including element order).
1004
+ # This is order-dependent: [A, B] does NOT match [A, B] but DOES match [B, A].
1005
+ #
1006
+ # q.where :field.neq => ["rock", "pop"]
1007
+ # q.where :tags.neq => [category1, category2] # for pointers
1008
+ #
1009
+ # @note This constraint uses aggregation pipeline with MongoDB $ne on arrays.
1010
+ #
1011
+ # @see ArrayEqConstraint
1012
+ # @see ArrayNotSetEqualsConstraint
1013
+ class ArrayNeqConstraint < Constraint
1014
+ # @!method neq
1015
+ # A registered method on a symbol to create the constraint.
1016
+ # @example
1017
+ # q.where :field.neq => ["value1", "value2"]
1018
+ # q.where :categories.neq => [cat1, cat2]
1019
+ # @return [ArrayNeqConstraint]
1020
+ register :neq
1021
+
1022
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1023
+ def build
1024
+ val = formatted_value
1025
+ val = [val].compact unless val.is_a?(Array)
1026
+
1027
+ field_name = Parse::Query.format_field(@operation.operand)
1028
+
1029
+ # Check if values are pointers (Parse objects or pointer objects)
1030
+ is_pointer_array = val.any? do |item|
1031
+ item.respond_to?(:pointer) || item.is_a?(Parse::Pointer)
1032
+ end
1033
+
1034
+ if is_pointer_array
1035
+ # Extract objectIds from pointers for comparison
1036
+ target_ids = val.map do |item|
1037
+ if item.respond_to?(:id)
1038
+ item.id
1039
+ elsif item.is_a?(Parse::Pointer)
1040
+ item.id
1041
+ else
1042
+ item
1043
+ end
1044
+ end
1045
+
1046
+ # Validate all IDs are present (unsaved objects have nil IDs)
1047
+ if target_ids.any?(&:nil?)
1048
+ raise ArgumentError, "#{self.class.name}: Cannot use unsaved objects (missing ID) in array constraint"
1049
+ end
1050
+
1051
+ # For pointer arrays, compare mapped objectIds with $ne (order matters).
1052
+ # $ifNull coerces a missing/null field to [] so $map doesn't raise a type
1053
+ # error on legacy documents that lack the field.
1054
+ pipeline = [
1055
+ {
1056
+ "$match" => {
1057
+ "$expr" => {
1058
+ "$ne" => [
1059
+ { "$map" => {
1060
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1061
+ "as" => "p",
1062
+ "in" => "$$p.objectId",
1063
+ } },
1064
+ target_ids,
1065
+ ],
1066
+ },
1067
+ },
1068
+ },
1069
+ ]
1070
+ else
1071
+ # For simple value arrays, direct $ne comparison (order matters).
1072
+ # $ifNull coerces a missing/null field to [] so a doc lacking the field
1073
+ # is treated the same as one with [], consistent with set_equals/arr_empty.
1074
+ pipeline = [
1075
+ {
1076
+ "$match" => {
1077
+ "$expr" => {
1078
+ "$ne" => [
1079
+ { "$ifNull" => ["$#{field_name}", []] },
1080
+ val,
1081
+ ],
1082
+ },
1083
+ },
1084
+ },
1085
+ ]
1086
+ end
1087
+
1088
+ { "__aggregation_pipeline" => pipeline }
1089
+ end
1090
+ end
1091
+
1092
+ # Not-set-equals constraint using MongoDB aggregation with $not and $setEquals.
1093
+ # Matches arrays that do NOT contain exactly the same elements (regardless of order).
1094
+ # This is order-independent: [A, B, C] does NOT match [A, B] but [C, B, A] DOES match.
1095
+ #
1096
+ # q.where :field.not_set_equals => ["rock", "pop"]
1097
+ # q.where :tags.not_set_equals => [category1, category2] # for pointers
1098
+ #
1099
+ # @note This constraint uses aggregation pipeline with MongoDB $not and $setEquals.
1100
+ #
1101
+ # @see ArraySetEqualsConstraint
1102
+ # @see ArrayNeqConstraint
1103
+ class ArrayNotSetEqualsConstraint < Constraint
1104
+ # @!method not_set_equals
1105
+ # A registered method on a symbol to create the constraint.
1106
+ # @example
1107
+ # q.where :field.not_set_equals => ["value1", "value2"]
1108
+ # q.where :categories.not_set_equals => [cat1, cat2]
1109
+ # @return [ArrayNotSetEqualsConstraint]
1110
+ register :not_set_equals
1111
+
1112
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1113
+ def build
1114
+ val = formatted_value
1115
+ val = [val].compact unless val.is_a?(Array)
1116
+
1117
+ field_name = Parse::Query.format_field(@operation.operand)
1118
+
1119
+ # Check if values are pointers (Parse objects or pointer objects)
1120
+ is_pointer_array = val.any? do |item|
1121
+ item.respond_to?(:pointer) || item.is_a?(Parse::Pointer)
1122
+ end
1123
+
1124
+ if is_pointer_array
1125
+ # Extract objectIds from pointers for comparison
1126
+ target_ids = val.map do |item|
1127
+ if item.respond_to?(:id)
1128
+ item.id
1129
+ elsif item.is_a?(Parse::Pointer)
1130
+ item.id
1131
+ else
1132
+ item
1133
+ end
1134
+ end
1135
+
1136
+ # Validate all IDs are present (unsaved objects have nil IDs)
1137
+ if target_ids.any?(&:nil?)
1138
+ raise ArgumentError, "#{self.class.name}: Cannot use unsaved objects (missing ID) in array constraint"
1139
+ end
1140
+
1141
+ # For pointer arrays, use $not with $setEquals on mapped objectIds.
1142
+ # $ifNull coerces a missing/null field to [] so $map doesn't raise a type
1143
+ # error on legacy documents that lack the field. A missing field is
1144
+ # treated the same as [] for set-equality purposes.
1145
+ pipeline = [
1146
+ {
1147
+ "$match" => {
1148
+ "$expr" => {
1149
+ "$not" => {
1150
+ "$setEquals" => [
1151
+ { "$map" => {
1152
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1153
+ "as" => "p",
1154
+ "in" => "$$p.objectId",
1155
+ } },
1156
+ target_ids,
1157
+ ],
1158
+ },
1159
+ },
1160
+ },
1161
+ },
1162
+ ]
1163
+ else
1164
+ # For simple value arrays, use $not with $setEquals.
1165
+ # $ifNull coerces a missing/null field to [] so $setEquals doesn't raise
1166
+ # a type error on legacy documents that lack the field.
1167
+ pipeline = [
1168
+ {
1169
+ "$match" => {
1170
+ "$expr" => {
1171
+ "$not" => {
1172
+ "$setEquals" => [
1173
+ { "$ifNull" => ["$#{field_name}", []] },
1174
+ val,
1175
+ ],
1176
+ },
1177
+ },
1178
+ },
1179
+ },
1180
+ ]
1181
+ end
1182
+
1183
+ { "__aggregation_pipeline" => pipeline }
1184
+ end
1185
+ end
1186
+
1187
+ # Element match constraint for arrays of objects.
1188
+ # Matches documents where at least one array element matches all specified criteria.
1189
+ #
1190
+ # # Find posts where comments array has an approved comment by the user
1191
+ # q.where :comments.elem_match => { author: user, approved: true }
1192
+ #
1193
+ # # Find items where tags array has a tag with specific properties
1194
+ # q.where :tags.elem_match => { name: "featured", priority: { "$gt" => 5 } }
1195
+ #
1196
+ # @note While $elemMatch is a standard MongoDB query operator, Parse Server's
1197
+ # REST API query endpoint does not support it directly (returns "bad constraint").
1198
+ # This constraint uses aggregation pipeline to work around this limitation.
1199
+ # Aggregation is efficient for complex multi-field element matching that would
1200
+ # otherwise require multiple queries or client-side filtering.
1201
+ #
1202
+ # @see ContainsAllConstraint
1203
+ class ArrayElemMatchConstraint < Constraint
1204
+ # @!method elem_match
1205
+ # A registered method on a symbol to create the constraint.
1206
+ # Uses aggregation pipeline since Parse Server doesn't support $elemMatch in queries.
1207
+ # @example
1208
+ # q.where :comments.elem_match => { author: user, approved: true }
1209
+ # @return [ArrayElemMatchConstraint]
1210
+ register :elem_match
1211
+
1212
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1213
+ def build
1214
+ val = formatted_value
1215
+ unless val.is_a?(Hash)
1216
+ raise ArgumentError, "#{self.class}: Value must be a hash of criteria for element matching"
1217
+ end
1218
+
1219
+ field_name = Parse::Query.format_field(@operation.operand)
1220
+
1221
+ # Convert any Parse objects to pointers in the criteria
1222
+ converted_val = convert_criteria(val)
1223
+
1224
+ # Build the aggregation pipeline with $elemMatch
1225
+ # Parse Server doesn't support $elemMatch as a native query constraint,
1226
+ # but it works within aggregation pipeline $match stages
1227
+ pipeline = [
1228
+ {
1229
+ "$match" => {
1230
+ field_name => {
1231
+ "$elemMatch" => converted_val,
1232
+ },
1233
+ },
1234
+ },
1235
+ ]
1236
+
1237
+ { "__aggregation_pipeline" => pipeline }
1238
+ end
1239
+
1240
+ private
1241
+
1242
+ def convert_criteria(criteria)
1243
+ criteria.transform_values do |v|
1244
+ if v.respond_to?(:pointer)
1245
+ v.pointer
1246
+ elsif v.is_a?(Hash)
1247
+ convert_criteria(v)
1248
+ else
1249
+ v
1250
+ end
1251
+ end
1252
+ end
1253
+ end
1254
+
1255
+ # Subset constraint - array only contains elements from the given set.
1256
+ # Uses MongoDB aggregation with $setIsSubset.
1257
+ #
1258
+ # # Find items where tags only contain elements from the allowed list
1259
+ # q.where :tags.subset_of => ["rock", "pop", "jazz"]
1260
+ #
1261
+ # # This will match:
1262
+ # # ["rock"] - yes (subset)
1263
+ # # ["rock", "pop"] - yes (subset)
1264
+ # # ["rock", "classical"] - no ("classical" not in allowed set)
1265
+ #
1266
+ # @note This constraint uses MongoDB aggregation pipeline with $setIsSubset.
1267
+ # While $expr expressions cannot utilize field indexes, aggregation enables
1268
+ # set operations not available in standard Parse queries.
1269
+ #
1270
+ # @see ContainsAllConstraint
1271
+ class ArraySubsetOfConstraint < Constraint
1272
+ # @!method subset_of
1273
+ # A registered method on a symbol to create the constraint.
1274
+ # @example
1275
+ # q.where :tags.subset_of => ["rock", "pop", "jazz"]
1276
+ # @return [ArraySubsetOfConstraint]
1277
+ register :subset_of
1278
+
1279
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1280
+ def build
1281
+ val = formatted_value
1282
+ val = [val].compact unless val.is_a?(Array)
1283
+
1284
+ field_name = Parse::Query.format_field(@operation.operand)
1285
+
1286
+ # Check if values are pointers
1287
+ is_pointer_array = val.any? do |item|
1288
+ item.respond_to?(:pointer) || item.is_a?(Parse::Pointer)
1289
+ end
1290
+
1291
+ if is_pointer_array
1292
+ # Extract objectIds from pointers
1293
+ target_ids = val.map do |item|
1294
+ if item.respond_to?(:id)
1295
+ item.id
1296
+ elsif item.is_a?(Parse::Pointer)
1297
+ item.id
1298
+ else
1299
+ item
1300
+ end
1301
+ end
1302
+
1303
+ # Validate all IDs are present (unsaved objects have nil IDs)
1304
+ if target_ids.any?(&:nil?)
1305
+ raise ArgumentError, "#{self.class.name}: Cannot use unsaved objects (missing ID) in array constraint"
1306
+ end
1307
+
1308
+ # $ifNull coerces a missing/null field to [] so $map (and $setIsSubset)
1309
+ # don't raise type errors on legacy documents that lack the field.
1310
+ # The empty set is a subset of every set, so a missing field matches
1311
+ # any subset_of target — consistent with treating missing as [].
1312
+ pipeline = [
1313
+ {
1314
+ "$match" => {
1315
+ "$expr" => {
1316
+ "$setIsSubset" => [
1317
+ { "$map" => {
1318
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1319
+ "as" => "p",
1320
+ "in" => "$$p.objectId",
1321
+ } },
1322
+ target_ids,
1323
+ ],
1324
+ },
1325
+ },
1326
+ },
1327
+ ]
1328
+ else
1329
+ # $ifNull coerces a missing/null field to [] so $setIsSubset doesn't
1330
+ # raise a type error on legacy documents that lack the field.
1331
+ pipeline = [
1332
+ {
1333
+ "$match" => {
1334
+ "$expr" => {
1335
+ "$setIsSubset" => [
1336
+ { "$ifNull" => ["$#{field_name}", []] },
1337
+ val,
1338
+ ],
1339
+ },
1340
+ },
1341
+ },
1342
+ ]
1343
+ end
1344
+
1345
+ { "__aggregation_pipeline" => pipeline }
1346
+ end
1347
+ end
1348
+
1349
+ # First element constraint - match based on the first element of an array.
1350
+ # Uses MongoDB aggregation with $arrayElemAt.
1351
+ #
1352
+ # q.where :tags.first => "rock" # first element equals "rock"
1353
+ #
1354
+ # @note This constraint uses MongoDB aggregation pipeline with $arrayElemAt.
1355
+ # While $expr expressions cannot utilize field indexes, aggregation enables
1356
+ # positional array access not available in standard Parse queries.
1357
+ #
1358
+ # @see ArrayLastConstraint
1359
+ class ArrayFirstConstraint < Constraint
1360
+ # @!method first
1361
+ # A registered method on a symbol to create the constraint.
1362
+ # @example
1363
+ # q.where :tags.first => "rock"
1364
+ # @return [ArrayFirstConstraint]
1365
+ register :first
1366
+
1367
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1368
+ def build
1369
+ val = formatted_value
1370
+ field_name = Parse::Query.format_field(@operation.operand)
1371
+
1372
+ # Handle pointer values. Wrap field reference in $ifNull so a missing
1373
+ # field is treated as [] (yielding null from $arrayElemAt) rather than
1374
+ # propagating a Missing value through the pipeline.
1375
+ if val.respond_to?(:id)
1376
+ compare_val = val.id
1377
+ pipeline = [
1378
+ {
1379
+ "$match" => {
1380
+ "$expr" => {
1381
+ "$eq" => [
1382
+ { "$arrayElemAt" => [{ "$map" => {
1383
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1384
+ "as" => "p",
1385
+ "in" => "$$p.objectId",
1386
+ } }, 0] },
1387
+ compare_val,
1388
+ ],
1389
+ },
1390
+ },
1391
+ },
1392
+ ]
1393
+ elsif val.is_a?(Parse::Pointer)
1394
+ compare_val = val.id
1395
+ pipeline = [
1396
+ {
1397
+ "$match" => {
1398
+ "$expr" => {
1399
+ "$eq" => [
1400
+ { "$arrayElemAt" => [{ "$map" => {
1401
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1402
+ "as" => "p",
1403
+ "in" => "$$p.objectId",
1404
+ } }, 0] },
1405
+ compare_val,
1406
+ ],
1407
+ },
1408
+ },
1409
+ },
1410
+ ]
1411
+ else
1412
+ pipeline = [
1413
+ {
1414
+ "$match" => {
1415
+ "$expr" => {
1416
+ "$eq" => [
1417
+ { "$arrayElemAt" => [{ "$ifNull" => ["$#{field_name}", []] }, 0] },
1418
+ val,
1419
+ ],
1420
+ },
1421
+ },
1422
+ },
1423
+ ]
1424
+ end
1425
+
1426
+ { "__aggregation_pipeline" => pipeline }
1427
+ end
1428
+ end
1429
+
1430
+ # Last element constraint - match based on the last element of an array.
1431
+ # Uses MongoDB aggregation with $arrayElemAt and index -1.
1432
+ #
1433
+ # q.where :tags.last => "pop" # last element equals "pop"
1434
+ #
1435
+ # @note This constraint uses MongoDB aggregation pipeline with $arrayElemAt.
1436
+ # While $expr expressions cannot utilize field indexes, aggregation enables
1437
+ # positional array access not available in standard Parse queries.
1438
+ #
1439
+ # @see ArrayFirstConstraint
1440
+ class ArrayLastConstraint < Constraint
1441
+ # @!method last
1442
+ # A registered method on a symbol to create the constraint.
1443
+ # @example
1444
+ # q.where :tags.last => "pop"
1445
+ # @return [ArrayLastConstraint]
1446
+ register :last
1447
+
1448
+ # @return [Hash] the compiled constraint using aggregation pipeline.
1449
+ def build
1450
+ val = formatted_value
1451
+ field_name = Parse::Query.format_field(@operation.operand)
1452
+
1453
+ # Handle pointer values. Wrap field reference in $ifNull so a missing
1454
+ # field is treated as [] (yielding null from $arrayElemAt) rather than
1455
+ # propagating a Missing value through the pipeline.
1456
+ if val.respond_to?(:id)
1457
+ compare_val = val.id
1458
+ pipeline = [
1459
+ {
1460
+ "$match" => {
1461
+ "$expr" => {
1462
+ "$eq" => [
1463
+ { "$arrayElemAt" => [{ "$map" => {
1464
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1465
+ "as" => "p",
1466
+ "in" => "$$p.objectId",
1467
+ } }, -1] },
1468
+ compare_val,
1469
+ ],
1470
+ },
1471
+ },
1472
+ },
1473
+ ]
1474
+ elsif val.is_a?(Parse::Pointer)
1475
+ compare_val = val.id
1476
+ pipeline = [
1477
+ {
1478
+ "$match" => {
1479
+ "$expr" => {
1480
+ "$eq" => [
1481
+ { "$arrayElemAt" => [{ "$map" => {
1482
+ "input" => { "$ifNull" => ["$#{field_name}", []] },
1483
+ "as" => "p",
1484
+ "in" => "$$p.objectId",
1485
+ } }, -1] },
1486
+ compare_val,
1487
+ ],
1488
+ },
1489
+ },
1490
+ },
1491
+ ]
1492
+ else
1493
+ pipeline = [
1494
+ {
1495
+ "$match" => {
1496
+ "$expr" => {
1497
+ "$eq" => [
1498
+ { "$arrayElemAt" => [{ "$ifNull" => ["$#{field_name}", []] }, -1] },
1499
+ val,
1500
+ ],
1501
+ },
1502
+ },
1503
+ },
1504
+ ]
1505
+ end
1506
+
1507
+ { "__aggregation_pipeline" => pipeline }
1508
+ end
1509
+ end
1510
+
1511
+ # Equivalent to the `$select` Parse query operation. This matches a value for a
1512
+ # key in the result of a different query.
1513
+ # q.where :field.select => { key: "field", query: query }
1514
+ #
1515
+ # # example
1516
+ # value = { key: 'city', query: Artist.where(:fan_count.gt => 50) }
1517
+ # q.where :hometown.select => value
1518
+ #
1519
+ # # if the local field is the same name as the foreign table field, you can omit hash
1520
+ # # assumes key: 'city'
1521
+ # q.where :city.select => Artist.where(:fan_count.gt => 50)
1522
+ #
1523
+ class SelectionConstraint < Constraint
1524
+ # @!method select
1525
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$select".
1526
+ # @return [SelectionConstraint]
1527
+ constraint_keyword :$select
1528
+ register :select
1529
+
1530
+ # @return [Hash] the compiled constraint.
1531
+ def build
1532
+
1533
+ # if it's a hash, then it should be {:key=>"objectId", :query=>[]}
1534
+ remote_field_name = @operation.operand
1535
+ query = nil
1536
+ if @value.is_a?(Hash)
1537
+ res = @value.symbolize_keys
1538
+ remote_field_name = res[:key] || remote_field_name
1539
+ query = res[:query]
1540
+ unless query.is_a?(Parse::Query)
1541
+ raise ArgumentError, "Invalid Parse::Query object provided in :query field of value: #{@operation.operand}.#{$dontSelect} => #{@value}"
1542
+ end
1543
+ query = query.compile(encode: false, includeClassName: true)
1544
+ elsif @value.is_a?(Parse::Query)
1545
+ # if its a query, then assume dontSelect key is the same name as operand.
1546
+ query = @value.compile(encode: false, includeClassName: true)
1547
+ else
1548
+ raise ArgumentError, "Invalid `:select` query constraint. It should follow the format: :field.select => { key: 'key', query: '<Parse::Query>' }"
1549
+ end
1550
+ { @operation.operand => { :$select => { key: remote_field_name, query: query } } }
1551
+ end
1552
+ end
1553
+
1554
+ # Equivalent to the `$dontSelect` Parse query operation. Requires that a field's
1555
+ # value not match a value for a key in the result of a different query.
1556
+ #
1557
+ # q.where :field.reject => { key: :other_field, query: query }
1558
+ #
1559
+ # value = { key: 'city', query: Artist.where(:fan_count.gt => 50) }
1560
+ # q.where :hometown.reject => value
1561
+ #
1562
+ # # if the local field is the same name as the foreign table field, you can omit hash
1563
+ # # assumes key: 'city'
1564
+ # q.where :city.reject => Artist.where(:fan_count.gt => 50)
1565
+ #
1566
+ # @see SelectionConstraint
1567
+ class RejectionConstraint < Constraint
1568
+
1569
+ # @!method dont_select
1570
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$dontSelect".
1571
+ # @example
1572
+ # q.where :field.reject => { key: :other_field, query: query }
1573
+ # @return [RejectionConstraint]
1574
+
1575
+ # @!method reject
1576
+ # Alias for {dont_select}
1577
+ # @return [RejectionConstraint]
1578
+ constraint_keyword :$dontSelect
1579
+ register :reject
1580
+ register :dont_select
1581
+
1582
+ # @return [Hash] the compiled constraint.
1583
+ def build
1584
+
1585
+ # if it's a hash, then it should be {:key=>"objectId", :query=>[]}
1586
+ remote_field_name = @operation.operand
1587
+ query = nil
1588
+ if @value.is_a?(Hash)
1589
+ res = @value.symbolize_keys
1590
+ remote_field_name = res[:key] || remote_field_name
1591
+ query = res[:query]
1592
+ unless query.is_a?(Parse::Query)
1593
+ raise ArgumentError, "Invalid Parse::Query object provided in :query field of value: #{@operation.operand}.#{$dontSelect} => #{@value}"
1594
+ end
1595
+ query = query.compile(encode: false, includeClassName: true)
1596
+ elsif @value.is_a?(Parse::Query)
1597
+ # if its a query, then assume dontSelect key is the same name as operand.
1598
+ query = @value.compile(encode: false, includeClassName: true)
1599
+ else
1600
+ raise ArgumentError, "Invalid `:reject` query constraint. It should follow the format: :field.reject => { key: 'key', query: '<Parse::Query>' }"
1601
+ end
1602
+ { @operation.operand => { :$dontSelect => { key: remote_field_name, query: query } } }
1603
+ end
1604
+ end
1605
+
1606
+ # Equivalent to the `$regex` Parse query operation. Requires that a field value
1607
+ # match a regular expression.
1608
+ #
1609
+ # q.where :field.like => /ruby_regex/i
1610
+ # :name.like => /Bob/i
1611
+ #
1612
+ class RegularExpressionConstraint < Constraint
1613
+ # Requires that a key's value match a regular expression.
1614
+ # Includes security validation to prevent ReDoS attacks.
1615
+
1616
+ # @!method like
1617
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
1618
+ # @example
1619
+ # q.where :field.like => /ruby_regex/i
1620
+ # @return [RegularExpressionConstraint]
1621
+
1622
+ # @!method regex
1623
+ # Alias for {like}
1624
+ # @return [RegularExpressionConstraint]
1625
+ constraint_keyword :$regex
1626
+ register :like
1627
+ register :regex
1628
+
1629
+ # Builds the regex constraint with security validation.
1630
+ # @raise [ArgumentError] if the pattern is potentially dangerous (ReDoS)
1631
+ # @return [Hash] the compiled constraint
1632
+ def build
1633
+ value = formatted_value
1634
+ pattern_str = value.is_a?(Regexp) ? value.source : value.to_s
1635
+ options = value.is_a?(Regexp) && value.casefold? ? "i" : nil
1636
+
1637
+ # Validate the regex pattern for ReDoS vulnerabilities
1638
+ Parse::RegexSecurity.validate!(pattern_str)
1639
+
1640
+ if options
1641
+ { @operation.operand => { key => pattern_str, :$options => options } }
1642
+ else
1643
+ { @operation.operand => { key => pattern_str } }
1644
+ end
1645
+ end
1646
+ end
1647
+
1648
+ # Equivalent to the `$relatedTo` Parse query operation. If you want to
1649
+ # retrieve objects that are members of a `Relation` field in your Parse class.
1650
+ #
1651
+ # q.where :field.related_to => pointer
1652
+ #
1653
+ # # find all Users who have liked this post object
1654
+ # post = Post.first
1655
+ # users = Parse::User.all :likes.related_to => post
1656
+ #
1657
+ class RelationQueryConstraint < Constraint
1658
+ # @!method related_to
1659
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$relatedTo".
1660
+ # @example
1661
+ # q.where :field.related_to => pointer
1662
+ # @return [RelationQueryConstraint]
1663
+
1664
+ # @!method rel
1665
+ # Alias for {related_to}
1666
+ # @return [RelationQueryConstraint]
1667
+ constraint_keyword :$relatedTo
1668
+ register :related_to
1669
+ register :rel
1670
+
1671
+ # @return [Hash] the compiled constraint.
1672
+ def build
1673
+ # pointer = formatted_value
1674
+ # unless pointer.is_a?(Parse::Pointer)
1675
+ # raise "Invalid Parse::Pointer passed to :related(#{@operation.operand}) constraint : #{pointer}"
1676
+ # end
1677
+ { :$relatedTo => { object: formatted_value, key: @operation.operand } }
1678
+ end
1679
+ end
1680
+
1681
+ # Equivalent to the `$inQuery` Parse query operation. Useful if you want to
1682
+ # retrieve objects where a field contains an object that matches another query.
1683
+ #
1684
+ # q.where :field.matches => query
1685
+ # # assume Post class has an image column.
1686
+ # q.where :post.matches => Post.where(:image.exists => true )
1687
+ #
1688
+ class InQueryConstraint < Constraint
1689
+ # @!method matches
1690
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$inQuery".
1691
+ # @example
1692
+ # q.where :field.matches => query
1693
+ # @return [InQueryConstraint]
1694
+
1695
+ # @!method in_query
1696
+ # Alias for {matches}
1697
+ # @return [InQueryConstraint]
1698
+ constraint_keyword :$inQuery
1699
+ register :matches
1700
+ register :in_query
1701
+ end
1702
+
1703
+ # Equivalent to the `$notInQuery` Parse query operation. Useful if you want to
1704
+ # retrieve objects where a field contains an object that does not match another query.
1705
+ # This is the inverse of the {InQueryConstraint}.
1706
+ #
1707
+ # q.where :field.excludes => query
1708
+ #
1709
+ # q.where :post.excludes => Post.where(:image.exists => true
1710
+ #
1711
+ class NotInQueryConstraint < Constraint
1712
+ # @!method excludes
1713
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$notInQuery".
1714
+ # @example
1715
+ # q.where :field.excludes => query
1716
+ # @return [NotInQueryConstraint]
1717
+
1718
+ # @!method not_in_query
1719
+ # Alias for {excludes}
1720
+ # @return [NotInQueryConstraint]
1721
+ constraint_keyword :$notInQuery
1722
+ register :excludes
1723
+ register :not_in_query
1724
+ end
1725
+
1726
+ # Equivalent to the `$nearSphere` Parse query operation. This is only applicable
1727
+ # if the field is of type `GeoPoint`. This will query Parse and return a list of
1728
+ # results ordered by distance with the nearest object being first.
1729
+ #
1730
+ # q.where :field.near => geopoint
1731
+ #
1732
+ # geopoint = Parse::GeoPoint.new(30.0, -20.0)
1733
+ # PlaceObject.all :location.near => geopoint
1734
+ # If you wish to constrain the geospatial query to a maximum number of _miles_,
1735
+ # you can utilize the `max_miles` method on a `Parse::GeoPoint` object. This
1736
+ # is equivalent to the `$maxDistanceInMiles` constraint used with `$nearSphere`.
1737
+ #
1738
+ # q.where :field.near => geopoint.max_miles(distance)
1739
+ # # or provide a triplet includes max miles constraint
1740
+ # q.where :field.near => [lat, lng, miles]
1741
+ #
1742
+ # geopoint = Parse::GeoPoint.new(30.0, -20.0)
1743
+ # PlaceObject.all :location.near => geopoint.max_miles(10)
1744
+ #
1745
+ # @todo Add support $maxDistanceInKilometers (for kms) and $maxDistanceInRadians (for radian angle).
1746
+ class NearSphereQueryConstraint < Constraint
1747
+ # @!method near
1748
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$nearSphere".
1749
+ # @example
1750
+ # q.where :field.near => geopoint
1751
+ # q.where :field.near => geopoint.max_miles(distance)
1752
+ # q.where :field.near => geopoint.max_kilometers(distance)
1753
+ # @return [NearSphereQueryConstraint]
1754
+ constraint_keyword :$nearSphere
1755
+ register :near
1756
+
1757
+ # Conversion factors mirror WithinSphereQueryConstraint so the cap
1758
+ # below is unit-agnostic.
1759
+ KM_PER_RADIAN = 6371.0
1760
+ MILES_PER_RADIAN = 3958.8
1761
+ # Whole-sphere cap (π radians ≈ 6371π km ≈ 3958.8π miles). A
1762
+ # `$nearSphere` with a larger `$maxDistanceIn*` defeats the
1763
+ # `2dsphere` early-termination optimization and degenerates to a
1764
+ # full geo scan.
1765
+ MAX_RADIANS = Math::PI
1766
+
1767
+ # @return [Hash] the compiled constraint.
1768
+ def build
1769
+ point = formatted_value
1770
+ max_distance = nil
1771
+ unit = :miles
1772
+ if point.is_a?(Array) && point.count > 1
1773
+ if point.count >= 3
1774
+ max_distance = point[2]
1775
+ # max_kilometers / max_radians tag the array with their unit
1776
+ # symbol in the 4th slot so we can dispatch to the right
1777
+ # Parse operator key. max_miles leaves slot 3 nil and the
1778
+ # default :miles applies.
1779
+ unit = point[3] if [:km, :radians].include?(point[3])
1780
+ end
1781
+ point = { __type: "GeoPoint", latitude: point[0], longitude: point[1] }
1782
+ end
1783
+ if max_distance.present? && max_distance > 0
1784
+ # Cap radius at whole-sphere coverage (π radians) regardless
1785
+ # of the chosen unit. Without this cap, an attacker-controlled
1786
+ # `max_*` (e.g. user-supplied km) can submit a huge value and
1787
+ # force a full-collection scan.
1788
+ radians_for_check =
1789
+ case unit
1790
+ when :km then max_distance.to_f / KM_PER_RADIAN
1791
+ when :radians then max_distance.to_f
1792
+ else max_distance.to_f / MILES_PER_RADIAN
1793
+ end
1794
+ if radians_for_check > MAX_RADIANS
1795
+ raise ArgumentError, "[Parse::Query] `near` max distance #{max_distance} #{unit} " \
1796
+ "(#{radians_for_check} radians) exceeds whole-sphere coverage " \
1797
+ "(π radians). A radius that large defeats the 2dsphere index " \
1798
+ "and degenerates $nearSphere into a full collection scan."
1799
+ end
1800
+
1801
+ distance_key =
1802
+ case unit
1803
+ when :km then :$maxDistanceInKilometers
1804
+ when :radians then :$maxDistance
1805
+ else :$maxDistanceInMiles
1806
+ end
1807
+ return { @operation.operand => { key => point, distance_key => max_distance.to_f } }
1808
+ end
1809
+ { @operation.operand => { key => point } }
1810
+ end
1811
+ end
1812
+
1813
+ # Equivalent to the `$within` Parse query operation and `$box` geopoint
1814
+ # constraint. The rectangular bounding box is defined by a southwest point as
1815
+ # the first parameter, followed by the a northeast point. Please note that Geo
1816
+ # box queries that cross the international date lines are not currently
1817
+ # supported by Parse.
1818
+ #
1819
+ # q.where :field.within_box => [soutwestGeoPoint, northeastGeoPoint]
1820
+ #
1821
+ # sw = Parse::GeoPoint.new 32.82, -117.23 # San Diego
1822
+ # ne = Parse::GeoPoint.new 36.12, -115.31 # Las Vegas
1823
+ #
1824
+ # # get all PlaceObjects inside this bounding box
1825
+ # PlaceObject.all :location.within_box => [sw,ne]
1826
+ #
1827
+ class WithinGeoBoxQueryConstraint < Constraint
1828
+ # @!method within_box
1829
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$within".
1830
+ # @example
1831
+ # q.where :field.within_box => [soutwestGeoPoint, northeastGeoPoint]
1832
+ # @return [WithinGeoBoxQueryConstraint]
1833
+ constraint_keyword :$within
1834
+ register :within_box
1835
+
1836
+ # @return [Hash] the compiled constraint.
1837
+ def build
1838
+ geopoint_values = formatted_value
1839
+ unless geopoint_values.is_a?(Array) && geopoint_values.count == 2 &&
1840
+ geopoint_values.first.is_a?(Parse::GeoPoint) && geopoint_values.last.is_a?(Parse::GeoPoint)
1841
+ raise(ArgumentError, "[Parse::Query] Invalid query value parameter passed to `within_box` constraint. " +
1842
+ "Values in array must be `Parse::GeoPoint` objects and " +
1843
+ "it should be in an array format: [southwestPoint, northeastPoint]")
1844
+ end
1845
+ { @operation.operand => { :$within => { :$box => geopoint_values } } }
1846
+ end
1847
+ end
1848
+
1849
+ # Equivalent to the `$geoWithin` Parse query operation and `$polygon` geopoints
1850
+ # constraint. The polygon area is defined by a list of {Parse::GeoPoint}
1851
+ # objects that make up the enclosed area. A polygon query should have 3 or more geopoints.
1852
+ # Please note that some Geo queries that cross the international date lines are not currently
1853
+ # supported by Parse.
1854
+ #
1855
+ # # As many points as you want, minimum 3
1856
+ # q.where :field.within_polygon => [geopoint1, geopoint2, geopoint3]
1857
+ #
1858
+ # # Polygon for the Bermuda Triangle
1859
+ # bermuda = Parse::GeoPoint.new 32.3078000,-64.7504999 # Bermuda
1860
+ # miami = Parse::GeoPoint.new 25.7823198,-80.2660226 # Miami, FL
1861
+ # san_juan = Parse::GeoPoint.new 18.3848232,-66.0933608 # San Juan, PR
1862
+ #
1863
+ # # get all sunken ships inside the Bermuda Triangle
1864
+ # SunkenShip.all :location.within_polygon => [bermuda, san_juan, miami]
1865
+ #
1866
+ class WithinPolygonQueryConstraint < Constraint
1867
+ # @!method within_polygon
1868
+ # A registered method on a symbol to create the constraint. Maps to Parse
1869
+ # operator "$geoWithin" with "$polygon" subconstraint. Takes either an
1870
+ # array of {Parse::GeoPoint} objects (legacy form) or a {Parse::Polygon}
1871
+ # instance (preferred when the polygon is already modeled).
1872
+ # @example
1873
+ # # As many points as you want
1874
+ # q.where :field.within_polygon => [geopoint1, geopoint2, geopoint3]
1875
+ #
1876
+ # # Or pass an existing Parse::Polygon directly. Parse Server accepts
1877
+ # # the same `{__type: "Polygon", coordinates: [...]}` object form for
1878
+ # # the `$polygon` argument as the legacy GeoPoint-array form.
1879
+ # q.where :field.within_polygon => polygon
1880
+ # @return [WithinPolygonQueryConstraint]
1881
+ # @version 1.7.0 (requires Server v2.4.2 or later)
1882
+ constraint_keyword :$geoWithin
1883
+ register :within_polygon
1884
+
1885
+ # @return [Hash] the compiled constraint.
1886
+ def build
1887
+ value = formatted_value
1888
+ if value.is_a?(Parse::Polygon)
1889
+ # Parse Server's REST `$polygon` operator expects the legacy
1890
+ # array-of-GeoPoint wire shape (each element a
1891
+ # `{__type: "GeoPoint", latitude:, longitude:}` hash). The
1892
+ # `{__type: "Polygon", coordinates: ...}` wrapper that
1893
+ # `Parse::Polygon#as_json` produces is the storage / property
1894
+ # shape, NOT a valid `$polygon` operand. If it reaches Parse
1895
+ # Server it is rejected with a 500; if it ever reached raw
1896
+ # MongoDB, `$polygon` requires `[lng, lat]` order while
1897
+ # `Parse::Polygon.coordinates` stores `[lat, lng]` — silent
1898
+ # axis swap. Convert here.
1899
+ geopoints = value.coordinates.map do |(lat, lng)|
1900
+ { __type: "GeoPoint", latitude: lat, longitude: lng }
1901
+ end
1902
+ return { @operation.operand => { :$geoWithin => { :$polygon => geopoints } } }
1903
+ end
1904
+
1905
+ unless value.is_a?(Array) &&
1906
+ value.all? { |point| point.is_a?(Parse::GeoPoint) } &&
1907
+ value.count > 2
1908
+ raise ArgumentError, "[Parse::Query] Invalid query value parameter passed to" \
1909
+ " `within_polygon` constraint: Value must be a Parse::Polygon, or an array" \
1910
+ " with 3 or more `Parse::GeoPoint` objects."
1911
+ end
1912
+
1913
+ { @operation.operand => { :$geoWithin => { :$polygon => value } } }
1914
+ end
1915
+ end
1916
+
1917
+ # Equivalent to the `$geoWithin` Parse query operation with `$centerSphere`
1918
+ # subconstraint. Filters a {Parse::GeoPoint} column by membership in a
1919
+ # circular region defined by a center point and a radius. Unlike
1920
+ # `:field.near => geopoint.max_*(N)`, this constraint does NOT order
1921
+ # results by distance, which makes it cheap and composable inside `$or`
1922
+ # branches and aggregation pipelines.
1923
+ #
1924
+ # The radius is in **radians** at the wire level. For convenience the DSL
1925
+ # accepts an Array tuple `[geopoint, distance, unit]` where `unit` may be
1926
+ # `:radians` (default), `:km` / `:kilometers`, or `:miles`; the SDK
1927
+ # converts to radians using mean-Earth-radius 6371 km / 3958.8 mi.
1928
+ #
1929
+ # q.where :location.within_sphere => [center, 5, :km]
1930
+ # q.where :location.within_sphere => [center, 10, :miles]
1931
+ # q.where :location.within_sphere => [center, 0.001] # radians
1932
+ #
1933
+ # @version 4.4.0 (requires Parse Server with `$centerSphere` support)
1934
+ class WithinSphereQueryConstraint < Constraint
1935
+ # @!method within_sphere
1936
+ # A registered method on a symbol to create the constraint. Maps to
1937
+ # Parse operator "$geoWithin" with "$centerSphere" subconstraint.
1938
+ # @example
1939
+ # q.where :location.within_sphere => [center_geopoint, 5, :km]
1940
+ # @return [WithinSphereQueryConstraint]
1941
+ constraint_keyword :$geoWithin
1942
+ register :within_sphere
1943
+
1944
+ KM_PER_RADIAN = 6371.0
1945
+ MILES_PER_RADIAN = 3958.8
1946
+ # Whole-sphere radius. `$centerSphere` with a radius greater than
1947
+ # π radians covers the entire sphere — a query that returns every
1948
+ # document in the collection and trivially defeats any `2dsphere`
1949
+ # index. Cap here so attacker-controlled radii (e.g. URL params
1950
+ # forwarded into the constraint) can't degenerate into a
1951
+ # full-collection scan.
1952
+ MAX_RADIANS = Math::PI
1953
+
1954
+ # @return [Hash] the compiled constraint.
1955
+ def build
1956
+ value = formatted_value
1957
+ unless value.is_a?(Array) && value.length >= 2
1958
+ raise ArgumentError, "[Parse::Query] Invalid value for `within_sphere` constraint: " \
1959
+ "expected [geopoint, distance, unit?]."
1960
+ end
1961
+
1962
+ point, distance, unit = value[0], value[1], (value[2] || :radians)
1963
+ unless point.is_a?(Parse::GeoPoint)
1964
+ raise ArgumentError, "[Parse::Query] `within_sphere` first element must be a Parse::GeoPoint."
1965
+ end
1966
+ unless distance.is_a?(Numeric) && distance > 0
1967
+ raise ArgumentError, "[Parse::Query] `within_sphere` distance must be a positive number."
1968
+ end
1969
+
1970
+ radians =
1971
+ case unit
1972
+ when :radians then distance.to_f
1973
+ when :km, :kilometers then distance.to_f / KM_PER_RADIAN
1974
+ when :miles then distance.to_f / MILES_PER_RADIAN
1975
+ else
1976
+ raise ArgumentError, "[Parse::Query] `within_sphere` unit must be :radians, :km, or :miles."
1977
+ end
1978
+
1979
+ if radians > MAX_RADIANS
1980
+ raise ArgumentError, "[Parse::Query] `within_sphere` radius #{distance} #{unit} " \
1981
+ "(#{radians} radians) exceeds whole-sphere coverage (π radians). " \
1982
+ "A radius that large covers the entire sphere and degenerates " \
1983
+ "$centerSphere into a full-collection scan."
1984
+ end
1985
+
1986
+ # MongoDB's $centerSphere expects [longitude, latitude]
1987
+ # (GeoJSON convention), not Parse's [latitude, longitude] order.
1988
+ center = [point.longitude, point.latitude]
1989
+ # `$centerSphere` is a native MongoDB geo operator, not a
1990
+ # documented Parse Server REST find operator. Mark this
1991
+ # constraint mongo-direct-only so the query layer auto-routes
1992
+ # to {Parse::Query#results_direct}; callers without a mongo
1993
+ # connection or master-key/scoped auth will get
1994
+ # `MongoDirectRequired` rather than a silently-wrong REST
1995
+ # result.
1996
+ {
1997
+ @operation.operand => { :$geoWithin => { :$centerSphere => [center, radians] } },
1998
+ "__mongo_direct_only" => true,
1999
+ }
2000
+ end
2001
+ end
2002
+
2003
+ # MongoDB-direct `$geoIntersects` with a full `$geometry` operand —
2004
+ # **NOT** a Parse Server REST operator. Returns documents whose stored
2005
+ # geometry intersects the supplied GeoJSON shape. Works against any
2006
+ # `2dsphere`-indexed column, including the `:object` columns where
2007
+ # {Parse::GeoJSON::LineString} / {Parse::GeoJSON::MultiPolygon}
2008
+ # geometries live.
2009
+ #
2010
+ # Because Parse Server's REST find layer cannot express this operator,
2011
+ # any query using `:field.geo_intersects` auto-routes through the
2012
+ # mongo-direct path (see {Parse::Query#requires_mongo_direct?}). The
2013
+ # routing gate requires `use_master_key: true` on the query AND a
2014
+ # configured {Parse::MongoDB} client — per-row ACL/CLP enforcement and
2015
+ # `beforeFind`/`afterFind` cloud triggers do not run on the
2016
+ # mongo-direct path. Use this constraint only when those concerns are
2017
+ # acceptable for the call site.
2018
+ #
2019
+ # route = Parse::GeoJSON::LineString.new [[-122.4, 37.7], [-122.39, 37.78]]
2020
+ # Region.query(:area.geo_intersects => route, :use_master_key => true).results
2021
+ #
2022
+ # @version 4.4.0
2023
+ class GeoIntersectsGeometryQueryConstraint < Constraint
2024
+ # @!method geo_intersects
2025
+ # A registered method on a symbol to create the constraint. Maps to
2026
+ # MongoDB's `$geoIntersects` with the `$geometry` operand. Direct-only.
2027
+ # @example
2028
+ # q.where :area.geo_intersects => parse_geojson_geometry
2029
+ # @return [GeoIntersectsGeometryQueryConstraint]
2030
+ constraint_keyword :$geoIntersects
2031
+ register :geo_intersects
2032
+
2033
+ # @return [Hash] the compiled constraint, carrying the
2034
+ # `__mongo_direct_only` routing marker so the query layer can
2035
+ # auto-route to {Parse::Query#results_direct}.
2036
+ def build
2037
+ geometry = coerce_to_geojson(formatted_value)
2038
+ {
2039
+ @operation.operand => {
2040
+ :$geoIntersects => { :$geometry => geometry },
2041
+ },
2042
+ "__mongo_direct_only" => true,
2043
+ }
2044
+ end
2045
+
2046
+ private
2047
+
2048
+ # RFC 7946 catalogue of valid GeoJSON geometry types. Allowlisting
2049
+ # here is defense-in-depth: MongoDB rejects unknown `$geometry`
2050
+ # types but a typo / attacker-controlled value should never reach
2051
+ # the wire. Forbids `$where`, `Polygon\u0000`, or any
2052
+ # non-geometry string.
2053
+ ALLOWED_GEOJSON_TYPES = %w[
2054
+ Point MultiPoint LineString MultiLineString
2055
+ Polygon MultiPolygon GeometryCollection
2056
+ ].freeze
2057
+
2058
+ def coerce_to_geojson(value)
2059
+ case value
2060
+ when Parse::GeoJSON::Geometry then value.to_geojson
2061
+ when Parse::Polygon then value.to_geojson
2062
+ when Parse::GeoPoint then value.to_geojson
2063
+ when Hash
2064
+ h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
2065
+ type = h[:type] || h["type"]
2066
+ coords = h[:coordinates] || h["coordinates"]
2067
+ unless type.is_a?(String) && ALLOWED_GEOJSON_TYPES.include?(type) && coords.is_a?(Array)
2068
+ raise ArgumentError, "[Parse::Query] `geo_intersects` Hash must be a GeoJSON geometry " \
2069
+ "with one of the RFC 7946 types " \
2070
+ "(#{ALLOWED_GEOJSON_TYPES.join(", ")}) and an Array of coordinates."
2071
+ end
2072
+ { "type" => type, "coordinates" => coords }
2073
+ else
2074
+ raise ArgumentError, "[Parse::Query] `geo_intersects` expects a Parse::GeoPoint, " \
2075
+ "Parse::Polygon, Parse::GeoJSON::Geometry, or GeoJSON Hash."
2076
+ end
2077
+ end
2078
+ end
2079
+
2080
+ # Equivalent to the `$geoIntersects` Parse query operation with `$point`
2081
+ # subconstraint. This is the inverse of `within_polygon`: it queries a
2082
+ # column of type `Polygon` and returns objects whose stored polygon
2083
+ # contains the supplied {Parse::GeoPoint}. Matches `Parse.Query#polygonContains`
2084
+ # in the JS SDK.
2085
+ #
2086
+ # q.where :area.polygon_contains => geopoint
2087
+ #
2088
+ # pt = Parse::GeoPoint.new 25.7823, -80.2660
2089
+ # Region.all :area.polygon_contains => pt
2090
+ #
2091
+ class PolygonContainsQueryConstraint < Constraint
2092
+ # @!method polygon_contains
2093
+ # A registered method on a symbol to create the constraint. Maps to
2094
+ # Parse operator "$geoIntersects" with "$point" subconstraint. Takes a
2095
+ # {Parse::GeoPoint} (or `[lat, lng]` array).
2096
+ # @example
2097
+ # q.where :area.polygon_contains => geopoint
2098
+ # @return [PolygonContainsQueryConstraint]
2099
+ constraint_keyword :$geoIntersects
2100
+ register :polygon_contains
2101
+
2102
+ # @return [Hash] the compiled constraint.
2103
+ def build
2104
+ value = formatted_value
2105
+ point =
2106
+ case value
2107
+ when Parse::GeoPoint
2108
+ { __type: "GeoPoint", latitude: value.latitude, longitude: value.longitude }
2109
+ when Array
2110
+ unless value.length == 2 && value[0].is_a?(Numeric) && value[1].is_a?(Numeric)
2111
+ raise ArgumentError, "[Parse::Query] Invalid value for `polygon_contains` constraint: " \
2112
+ "expected Parse::GeoPoint or [lat, lng] numeric pair."
2113
+ end
2114
+ { __type: "GeoPoint", latitude: value[0], longitude: value[1] }
2115
+ when Hash
2116
+ normalized = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
2117
+ type = normalized[:__type] || normalized["__type"]
2118
+ lat = normalized[:latitude] || normalized[:lat]
2119
+ lng = normalized[:longitude] || normalized[:lng]
2120
+ unless type.to_s == "GeoPoint" && lat.is_a?(Numeric) && lng.is_a?(Numeric)
2121
+ raise ArgumentError, "[Parse::Query] Invalid value for `polygon_contains` constraint: " \
2122
+ "Hash must be the GeoPoint wire shape " \
2123
+ "{ __type: 'GeoPoint', latitude:, longitude: }."
2124
+ end
2125
+ { __type: "GeoPoint", latitude: lat, longitude: lng }
2126
+ else
2127
+ raise ArgumentError, "[Parse::Query] Invalid value for `polygon_contains` constraint: " \
2128
+ "expected Parse::GeoPoint or [lat, lng] numeric pair."
2129
+ end
2130
+
2131
+ { @operation.operand => { :$geoIntersects => { :$point => point } } }
2132
+ end
2133
+ end
2134
+
2135
+ # Equivalent to the full text search support with `$text` with a set of search crieteria.
2136
+ class FullTextSearchQueryConstraint < Constraint
2137
+ # @!method text_search
2138
+ # A registered method on a symbol to create the constraint. Maps to Parse
2139
+ # operator "$text" with "$search" subconstraint. Takes a hash of parameters.
2140
+ # @example
2141
+ # # As many points as you want
2142
+ # q.where :field.text_search => {parameters}
2143
+ #
2144
+ # Where `parameters` can be one of:
2145
+ # $term : Specify a field to search (Required)
2146
+ # $language : Determines the list of stop words and the rules for tokenizer.
2147
+ # $caseSensitive : Enable or disable case sensitive search.
2148
+ # $diacriticSensitive : Enable or disable diacritic sensitive search
2149
+ #
2150
+ # @note This method will automatically add `$` to each key of the parameters
2151
+ # hash if it doesn't already have it.
2152
+ # @return [WithinPolygonQueryConstraint]
2153
+ # @version 1.8.0 (requires Server v2.5.0 or later)
2154
+ constraint_keyword :$text
2155
+ register :text_search
2156
+
2157
+ # @return [Hash] the compiled constraint.
2158
+ def build
2159
+ params = formatted_value
2160
+
2161
+ params = { :$term => params.to_s } if params.is_a?(String) || params.is_a?(Symbol)
2162
+
2163
+ unless params.is_a?(Hash)
2164
+ raise ArgumentError, "[Parse::Query] Invalid query value parameter passed to" \
2165
+ " `text_search` constraint: Value must be a string or a hash of parameters."
2166
+ end
2167
+
2168
+ params = params.inject({}) do |h, (k, v)|
2169
+ u = k.to_s
2170
+ u = u.columnize.prepend("$") unless u.start_with?("$")
2171
+ h[u] = v
2172
+ h
2173
+ end
2174
+
2175
+ unless params["$term"].present?
2176
+ raise ArgumentError, "[Parse::Query] Invalid query value parameter passed to" \
2177
+ " `text_search` constraint: Missing required `$term` subkey.\n" \
2178
+ "\tExample: #{@operation.operand}.text_search => { term: 'text to search' }"
2179
+ end
2180
+
2181
+ { @operation.operand => { :$text => { :$search => params } } }
2182
+ end
2183
+ end
2184
+
2185
+ # Equivalent to the `$select` Parse query operation but for key matching.
2186
+ # This matches objects where a field's value equals another field's value from a different query.
2187
+ # Useful for performing join-like operations where fields from different classes match.
2188
+ #
2189
+ # # Find users where user.company equals customer.company
2190
+ # customer_query = Customer.where(:active => true)
2191
+ # user_query = User.where(:company.matches_key => { key: "company", query: customer_query })
2192
+ #
2193
+ # # If the local field has the same name as the remote field, you can omit the key
2194
+ # # assumes key: 'company'
2195
+ # user_query = User.where(:company.matches_key => customer_query)
2196
+ #
2197
+ class MatchesKeyInQueryConstraint < Constraint
2198
+ # @!method matches_key_in_query
2199
+ # A registered method on a symbol to create the constraint.
2200
+ # @example
2201
+ # q.where :field.matches_key_in_query => { key: "remote_field", query: query }
2202
+ # q.where :field.matches_key_in_query => query # assumes same field name
2203
+ # @return [MatchesKeyInQueryConstraint]
2204
+
2205
+ # @!method matches_key
2206
+ # Alias for {matches_key_in_query}
2207
+ # @return [MatchesKeyInQueryConstraint]
2208
+ constraint_keyword :$select
2209
+ register :matches_key_in_query
2210
+ register :matches_key
2211
+
2212
+ # @return [Hash] the compiled constraint.
2213
+ def build
2214
+ remote_field_name = @operation.operand
2215
+ query = nil
2216
+
2217
+ if @value.is_a?(Hash)
2218
+ res = @value.symbolize_keys
2219
+ remote_field_name = res[:key] || remote_field_name
2220
+ query = res[:query]
2221
+ unless query.is_a?(Parse::Query)
2222
+ raise ArgumentError, "Invalid Parse::Query object provided in :query field of value: #{@operation.operand}.matches_key_in_query => #{@value}"
2223
+ end
2224
+ query = query.compile(encode: false, includeClassName: true)
2225
+ elsif @value.is_a?(Parse::Query)
2226
+ # if its a query, then assume key is the same name as operand.
2227
+ query = @value.compile(encode: false, includeClassName: true)
2228
+ else
2229
+ raise ArgumentError, "Invalid `:matches_key_in_query` query constraint. It should follow the format: :field.matches_key_in_query => { key: 'key', query: '<Parse::Query>' }"
2230
+ end
2231
+
2232
+ { @operation.operand => { :$select => { key: remote_field_name, query: query } } }
2233
+ end
2234
+ end
2235
+
2236
+ # Equivalent to the `$dontSelect` Parse query operation but for key matching.
2237
+ # This matches objects where a field's value does NOT equal another field's value from a different query.
2238
+ # This is the inverse of the {MatchesKeyInQueryConstraint}.
2239
+ #
2240
+ # # Find users where user.company does NOT equal customer.company
2241
+ # customer_query = Customer.where(:active => true)
2242
+ # user_query = User.where(:company.does_not_match_key => { key: "company", query: customer_query })
2243
+ #
2244
+ # # If the local field has the same name as the remote field, you can omit the key
2245
+ # # assumes key: 'company'
2246
+ # user_query = User.where(:company.does_not_match_key => customer_query)
2247
+ #
2248
+ class DoesNotMatchKeyInQueryConstraint < Constraint
2249
+ # @!method does_not_match_key_in_query
2250
+ # A registered method on a symbol to create the constraint.
2251
+ # @example
2252
+ # q.where :field.does_not_match_key_in_query => { key: "remote_field", query: query }
2253
+ # q.where :field.does_not_match_key_in_query => query # assumes same field name
2254
+ # @return [DoesNotMatchKeyInQueryConstraint]
2255
+
2256
+ # @!method does_not_match_key
2257
+ # Alias for {does_not_match_key_in_query}
2258
+ # @return [DoesNotMatchKeyInQueryConstraint]
2259
+ constraint_keyword :$dontSelect
2260
+ register :does_not_match_key_in_query
2261
+ register :does_not_match_key
2262
+
2263
+ # @return [Hash] the compiled constraint.
2264
+ def build
2265
+ remote_field_name = @operation.operand
2266
+ query = nil
2267
+
2268
+ if @value.is_a?(Hash)
2269
+ res = @value.symbolize_keys
2270
+ remote_field_name = res[:key] || remote_field_name
2271
+ query = res[:query]
2272
+ unless query.is_a?(Parse::Query)
2273
+ raise ArgumentError, "Invalid Parse::Query object provided in :query field of value: #{@operation.operand}.does_not_match_key_in_query => #{@value}"
2274
+ end
2275
+ query = query.compile(encode: false, includeClassName: true)
2276
+ elsif @value.is_a?(Parse::Query)
2277
+ # if its a query, then assume key is the same name as operand.
2278
+ query = @value.compile(encode: false, includeClassName: true)
2279
+ else
2280
+ raise ArgumentError, "Invalid `:does_not_match_key_in_query` query constraint. It should follow the format: :field.does_not_match_key_in_query => { key: 'key', query: '<Parse::Query>' }"
2281
+ end
2282
+
2283
+ { @operation.operand => { :$dontSelect => { key: remote_field_name, query: query } } }
2284
+ end
2285
+ end
2286
+
2287
+ # Equivalent to using the `$regex` Parse query operation with a prefix pattern.
2288
+ # This is useful for autocomplete functionality and prefix matching.
2289
+ #
2290
+ # # Find users whose name starts with "John"
2291
+ # User.where(:name.starts_with => "John")
2292
+ # # Generates: "name": { "$regex": "^John", "$options": "i" }
2293
+ #
2294
+ class StartsWithConstraint < Constraint
2295
+ # @!method starts_with
2296
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
2297
+ # @example
2298
+ # q.where :field.starts_with => "prefix"
2299
+ # @return [StartsWithConstraint]
2300
+ constraint_keyword :$regex
2301
+ register :starts_with
2302
+
2303
+ # @return [Hash] the compiled constraint.
2304
+ def build
2305
+ value = formatted_value
2306
+ unless value.is_a?(String)
2307
+ raise ArgumentError, "#{self.class}: Value must be a string for starts_with constraint"
2308
+ end
2309
+
2310
+ # Validate length to prevent performance issues
2311
+ if value.length > Parse::RegexSecurity::MAX_PATTERN_LENGTH
2312
+ raise ArgumentError, "#{self.class}: Value too long (#{value.length} chars, max #{Parse::RegexSecurity::MAX_PATTERN_LENGTH})"
2313
+ end
2314
+
2315
+ # Escape special regex characters in the prefix
2316
+ escaped_value = Regexp.escape(value)
2317
+ regex_pattern = "^#{escaped_value}"
2318
+
2319
+ { @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
2320
+ end
2321
+ end
2322
+
2323
+ # Equivalent to using the `$regex` Parse query operation with a contains pattern.
2324
+ # This is useful for case-insensitive text search within fields.
2325
+ #
2326
+ # # Find posts whose title contains "parse"
2327
+ # Post.where(:title.contains => "parse")
2328
+ # # Generates: "title": { "$regex": ".*parse.*", "$options": "i" }
2329
+ #
2330
+ class ContainsConstraint < Constraint
2331
+ # @!method contains
2332
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
2333
+ # @example
2334
+ # q.where :field.contains => "text"
2335
+ # @return [ContainsConstraint]
2336
+ constraint_keyword :$regex
2337
+ register :contains
2338
+
2339
+ # @return [Hash] the compiled constraint.
2340
+ def build
2341
+ value = formatted_value
2342
+ unless value.is_a?(String)
2343
+ raise ArgumentError, "#{self.class}: Value must be a string for contains constraint"
2344
+ end
2345
+
2346
+ # Validate length to prevent performance issues
2347
+ if value.length > Parse::RegexSecurity::MAX_PATTERN_LENGTH
2348
+ raise ArgumentError, "#{self.class}: Value too long (#{value.length} chars, max #{Parse::RegexSecurity::MAX_PATTERN_LENGTH})"
2349
+ end
2350
+
2351
+ # Escape special regex characters in the search text
2352
+ escaped_value = Regexp.escape(value)
2353
+ regex_pattern = ".*#{escaped_value}.*"
2354
+
2355
+ { @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
2356
+ end
2357
+ end
2358
+
2359
+ # Equivalent to using the `$regex` Parse query operation with a suffix pattern.
2360
+ # This is useful for matching fields that end with a specific string.
2361
+ #
2362
+ # # Find files whose name ends with ".pdf"
2363
+ # File.where(:name.ends_with => ".pdf")
2364
+ # # Generates: "name": { "$regex": "\\.pdf$", "$options": "i" }
2365
+ #
2366
+ class EndsWithConstraint < Constraint
2367
+ # @!method ends_with
2368
+ # A registered method on a symbol to create the constraint. Maps to Parse operator "$regex".
2369
+ # @example
2370
+ # q.where :field.ends_with => "suffix"
2371
+ # @return [EndsWithConstraint]
2372
+ constraint_keyword :$regex
2373
+ register :ends_with
2374
+
2375
+ # @return [Hash] the compiled constraint.
2376
+ def build
2377
+ value = formatted_value
2378
+ unless value.is_a?(String)
2379
+ raise ArgumentError, "#{self.class}: Value must be a string for ends_with constraint"
2380
+ end
2381
+
2382
+ # Validate length to prevent performance issues
2383
+ if value.length > Parse::RegexSecurity::MAX_PATTERN_LENGTH
2384
+ raise ArgumentError, "#{self.class}: Value too long (#{value.length} chars, max #{Parse::RegexSecurity::MAX_PATTERN_LENGTH})"
2385
+ end
2386
+
2387
+ # Escape special regex characters in the suffix
2388
+ escaped_value = Regexp.escape(value)
2389
+ regex_pattern = "#{escaped_value}$"
2390
+
2391
+ { @operation.operand => { :$regex => regex_pattern, :$options => "i" } }
2392
+ end
2393
+ end
2394
+
2395
+ # A convenience constraint that combines greater-than-or-equal and less-than-or-equal
2396
+ # constraints for date/time range queries. This is equivalent to using both $gte and $lte.
2397
+ #
2398
+ # # Find events between two dates
2399
+ # Event.where(:created_at.between_dates => [start_date, end_date])
2400
+ # # Generates: "created_at": { "$gte": start_date, "$lte": end_date }
2401
+ #
2402
+ class TimeRangeConstraint < Constraint
2403
+ # @!method between_dates
2404
+ # A registered method on a symbol to create the constraint.
2405
+ # @example
2406
+ # q.where :field.between_dates => [start_date, end_date]
2407
+ # @return [TimeRangeConstraint]
2408
+ register :between_dates
2409
+
2410
+ # @return [Hash] the compiled constraint.
2411
+ def build
2412
+ value = formatted_value
2413
+ unless value.is_a?(Array) && value.length == 2
2414
+ raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [start_date, end_date]"
2415
+ end
2416
+
2417
+ start_date, end_date = value
2418
+
2419
+ # Format the dates using Parse's date formatting
2420
+ formatted_start = Parse::Constraint.formatted_value(start_date)
2421
+ formatted_end = Parse::Constraint.formatted_value(end_date)
2422
+
2423
+ { @operation.operand => {
2424
+ Parse::Constraint::GreaterThanOrEqualConstraint.key => formatted_start,
2425
+ Parse::Constraint::LessThanOrEqualConstraint.key => formatted_end,
2426
+ } }
2427
+ end
2428
+ end
2429
+
2430
+ # A general range constraint that combines greater-than-or-equal and less-than-or-equal
2431
+ # constraints for numeric, date/time, and string range queries. This is equivalent to using both $gte and $lte.
2432
+ # This constraint works with numbers, dates, times, strings (alphabetical), and any comparable values.
2433
+ #
2434
+ # # Find products with price between 10 and 50
2435
+ # Product.where(:price.between => [10, 50])
2436
+ # # Generates: "price": { "$gte": 10, "$lte": 50 }
2437
+ #
2438
+ # # Find events between two dates
2439
+ # Event.where(:created_at.between => [start_date, end_date])
2440
+ # # Generates: "created_at": { "$gte": start_date, "$lte": end_date }
2441
+ #
2442
+ # # Find users with age between 18 and 65
2443
+ # User.where(:age.between => [18, 65])
2444
+ # # Generates: "age": { "$gte": 18, "$lte": 65 }
2445
+ #
2446
+ # # Find users with names alphabetically between "Alice" and "John"
2447
+ # User.where(:name.between => ["Alice", "John"])
2448
+ # # Generates: "name": { "$gte": "Alice", "$lte": "John" }
2449
+ #
2450
+ class BetweenConstraint < Constraint
2451
+ # @!method between
2452
+ # A registered method on a symbol to create the constraint.
2453
+ # @example
2454
+ # q.where :field.between => [min_value, max_value]
2455
+ # @return [BetweenConstraint]
2456
+ register :between
2457
+
2458
+ # @return [Hash] the compiled constraint.
2459
+ def build
2460
+ value = formatted_value
2461
+ unless value.is_a?(Array) && value.length == 2
2462
+ raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [min_value, max_value]"
2463
+ end
2464
+
2465
+ min_value, max_value = value
2466
+
2467
+ # Format the values using Parse's formatting (handles dates, numbers, etc.)
2468
+ formatted_min = Parse::Constraint.formatted_value(min_value)
2469
+ formatted_max = Parse::Constraint.formatted_value(max_value)
2470
+
2471
+ { @operation.operand => {
2472
+ Parse::Constraint::GreaterThanOrEqualConstraint.key => formatted_min,
2473
+ Parse::Constraint::LessThanOrEqualConstraint.key => formatted_max,
2474
+ } }
2475
+ end
2476
+ end
2477
+
2478
+ # @!visibility private
2479
+ # Shared helpers for the four ACL constraint subclasses
2480
+ # (+:ACL.readable_by+, +:ACL.readable_by_role+, +:ACL.writable_by+,
2481
+ # +:ACL.writable_by_role+). Collects a list of permission strings
2482
+ # from a caller-supplied value of User/Role/Pointer/Array/String,
2483
+ # using {Parse::Role.all_for_user} (user inputs) and
2484
+ # {Parse::Role#all_parent_role_names} (role inputs) so the
2485
+ # traversal walks the inheritance direction Parse Server actually
2486
+ # enforces. Prior implementations inlined +role.all_child_roles+,
2487
+ # which traverses the wrong direction and over-grants.
2488
+ module ACLPermissions
2489
+ module_function
2490
+
2491
+ # Expand a +:ACL.readable_by+ / +:ACL.writable_by+ value into a
2492
+ # permission-string array. Uses explicit +is_a?+ calls (rather
2493
+ # than +case/when+) so callers that pass duck-typed mocks with
2494
+ # overridden +is_a?+ — common in the constraint test suite —
2495
+ # continue to route correctly.
2496
+ # @param value [Parse::User, Parse::Role, Parse::Pointer, String, Array]
2497
+ # @return [Array<String>]
2498
+ def collect(value)
2499
+ if value.is_a?(Parse::User)
2500
+ permissions_for_user(value)
2501
+ elsif value.is_a?(Parse::Role)
2502
+ permissions_for_role(value)
2503
+ elsif value.is_a?(Parse::Pointer)
2504
+ permissions_for_pointer(value)
2505
+ elsif value.is_a?(Array)
2506
+ value.flat_map { |item| collect_array_item(item) }
2507
+ elsif value.is_a?(String)
2508
+ [value == "public" ? "*" : value]
2509
+ else
2510
+ raise ArgumentError,
2511
+ "ACL permission value must be a Parse::User, Parse::Role, " \
2512
+ "Parse::Pointer, String, or Array of these (got #{value.class})"
2513
+ end
2514
+ end
2515
+
2516
+ # Expand a +:ACL.readable_by_role+ / +:ACL.writable_by_role+ value
2517
+ # into a permission-string array. Differs from {.collect} by
2518
+ # auto-prefixing bare strings with +"role:"+ and refusing
2519
+ # non-role arguments.
2520
+ # @param value [Parse::Role, Parse::Pointer, String, Array]
2521
+ # @return [Array<String>]
2522
+ def collect_role_only(value)
2523
+ if value.is_a?(Parse::Role)
2524
+ permissions_for_role(value)
2525
+ elsif value.is_a?(Parse::Pointer)
2526
+ klass = value.parse_class
2527
+ if klass == Parse::Model::CLASS_ROLE || klass == "Role"
2528
+ permissions_for_role_pointer(value)
2529
+ else
2530
+ raise ArgumentError,
2531
+ "ACL role-only value pointer must be on _Role (got #{klass.inspect})"
2532
+ end
2533
+ elsif value.is_a?(Array)
2534
+ value.flat_map { |item| collect_role_only_array_item(item) }
2535
+ elsif value.is_a?(String)
2536
+ [value.start_with?("role:") ? value : "role:#{value}"]
2537
+ else
2538
+ raise ArgumentError,
2539
+ "ACL role-only value must be a Parse::Role, Parse::Role pointer, " \
2540
+ "String, or Array of these (got #{value.class})"
2541
+ end
2542
+ end
2543
+
2544
+ # @!visibility private
2545
+ # Array-element variant that silently skips unrecognized entries
2546
+ # rather than raising, matching the pre-refactor behavior where
2547
+ # the array branch tolerated a mixed bag of types and ignored
2548
+ # anything it didn't understand.
2549
+ def collect_array_item(item)
2550
+ if item.is_a?(Parse::User)
2551
+ permissions_for_user(item)
2552
+ elsif item.is_a?(Parse::Role)
2553
+ permissions_for_role(item)
2554
+ elsif item.is_a?(Parse::Pointer)
2555
+ permissions_for_pointer(item)
2556
+ elsif item.is_a?(String)
2557
+ [item == "public" ? "*" : item]
2558
+ else
2559
+ []
2560
+ end
2561
+ end
2562
+
2563
+ # @!visibility private
2564
+ # Array-element variant for the role-only collectors.
2565
+ def collect_role_only_array_item(item)
2566
+ if item.is_a?(Parse::Role)
2567
+ permissions_for_role(item)
2568
+ elsif item.is_a?(Parse::Pointer)
2569
+ klass = item.parse_class
2570
+ if klass == Parse::Model::CLASS_ROLE || klass == "Role"
2571
+ permissions_for_role_pointer(item)
2572
+ else
2573
+ []
2574
+ end
2575
+ elsif item.is_a?(String)
2576
+ [item.start_with?("role:") ? item : "role:#{item}"]
2577
+ else
2578
+ []
2579
+ end
2580
+ end
2581
+
2582
+ # Compile a final permission-string array into an aggregation
2583
+ # pipeline match-stage on the requested permission field. The
2584
+ # +$exists: false+ branch is appended by {Parse::ACL.read_predicate}
2585
+ # / {Parse::ACL.write_predicate} so a missing +_rperm+/+_wperm+
2586
+ # (treated as public by Parse Server) still matches.
2587
+ # @param permissions [Array<String>]
2588
+ # @param field [String] +"_rperm"+ or +"_wperm"+.
2589
+ # @return [Hash] aggregation-pipeline wrapper compatible with
2590
+ # {Parse::Query}'s constraint-build contract.
2591
+ def pipeline(permissions, field:)
2592
+ deduped = permissions.compact.reject(&:empty?).uniq
2593
+ if deduped.empty?
2594
+ raise ArgumentError, "no valid permissions found in provided value"
2595
+ end
2596
+ predicate = if field == "_rperm"
2597
+ Parse::ACL.read_predicate(deduped)
2598
+ else
2599
+ Parse::ACL.write_predicate(deduped)
2600
+ end
2601
+ { "__aggregation_pipeline" => [{ "$match" => predicate }] }
2602
+ end
2603
+
2604
+ # @!visibility private
2605
+ def permissions_for_user(user)
2606
+ return [] unless user.id.present?
2607
+ perms = [user.id]
2608
+ begin
2609
+ Parse::Role.all_for_user(user, max_depth: 5).each do |name|
2610
+ perms << "role:#{name}"
2611
+ end
2612
+ rescue
2613
+ # Fall through with just the user ID; role expansion is
2614
+ # best-effort and a transient lookup failure must not turn a
2615
+ # legitimate user query into an exception.
2616
+ end
2617
+ perms
2618
+ end
2619
+
2620
+ # @!visibility private
2621
+ def permissions_for_role(role)
2622
+ return [] unless role.respond_to?(:name) && role.name.present?
2623
+ begin
2624
+ role.all_parent_role_names(max_depth: 5).map { |name| "role:#{name}" }
2625
+ rescue
2626
+ ["role:#{role.name}"]
2627
+ end
2628
+ end
2629
+
2630
+ # @!visibility private
2631
+ def permissions_for_pointer(ptr)
2632
+ klass = ptr.parse_class
2633
+ if klass == Parse::Model::CLASS_USER || klass == "User"
2634
+ return [] unless ptr.id.present?
2635
+ perms = [ptr.id]
2636
+ begin
2637
+ Parse::Role.all_for_user(ptr, max_depth: 5).each do |name|
2638
+ perms << "role:#{name}"
2639
+ end
2640
+ rescue
2641
+ end
2642
+ perms
2643
+ elsif klass == Parse::Model::CLASS_ROLE || klass == "Role"
2644
+ permissions_for_role_pointer(ptr)
2645
+ else
2646
+ []
2647
+ end
2648
+ end
2649
+
2650
+ # @!visibility private
2651
+ def permissions_for_role_pointer(ptr)
2652
+ role = ptr.respond_to?(:fetch) ? ptr.fetch : nil
2653
+ permissions_for_role(role)
2654
+ rescue
2655
+ []
2656
+ end
2657
+ end
2658
+
2659
+ # A constraint for filtering objects based on ACL read permissions.
2660
+ # This constraint queries the MongoDB _rperm field directly.
2661
+ # Strings are used as exact permission values (user IDs or "role:RoleName" format).
2662
+ #
2663
+ # For role-based filtering with automatic "role:" prefix, use readable_by_role instead.
2664
+ #
2665
+ # # Find objects readable by a specific user object (fetches user's roles automatically)
2666
+ # Post.where(:ACL.readable_by => user)
2667
+ #
2668
+ # # Find objects readable by exact permission strings (no prefix added)
2669
+ # Post.where(:ACL.readable_by => "user123") # User ID
2670
+ # Post.where(:ACL.readable_by => "role:Admin") # Role with explicit prefix
2671
+ # Post.where(:ACL.readable_by => ["user123", "role:Admin"])
2672
+ #
2673
+ class ACLReadableByConstraint < Constraint
2674
+ # @!method readable_by
2675
+ # A registered method on a symbol to create the constraint.
2676
+ # @example
2677
+ # q.where :ACL.readable_by => user_or_permission_strings
2678
+ # @return [ACLReadableByConstraint]
2679
+ register :readable_by
2680
+
2681
+ # @return [Hash] the compiled constraint using _rperm field.
2682
+ def build
2683
+ # Use @value directly to preserve type information before
2684
+ # formatted_value converts to pointers.
2685
+ value = @value
2686
+
2687
+ # Special case: "none" matches objects whose _rperm is an empty
2688
+ # array — master-key-only documents. Parse Server writes []
2689
+ # when no read permission is set, and an absent _rperm is
2690
+ # treated as public (handled by the default predicate path).
2691
+ if value.is_a?(String) && value == "none"
2692
+ pipeline = [{ "$match" => { "_rperm" => { "$eq" => [] } } }]
2693
+ return { "__aggregation_pipeline" => pipeline }
2694
+ end
2695
+
2696
+ permissions = ACLPermissions.collect(value)
2697
+ ACLPermissions.pipeline(permissions, field: "_rperm")
2698
+ end
2699
+ end
2700
+
2701
+ # A constraint for filtering objects readable by specific role names.
2702
+ # Automatically adds "role:" prefix to role names.
2703
+ #
2704
+ # # Find objects readable by Admin role (string - adds role: prefix)
2705
+ # Post.where(:ACL.readable_by_role => "Admin")
2706
+ #
2707
+ # # Find objects readable by Role object
2708
+ # Post.where(:ACL.readable_by_role => admin_role)
2709
+ #
2710
+ # # Find objects readable by multiple roles
2711
+ # Post.where(:ACL.readable_by_role => ["Admin", "Moderator"])
2712
+ #
2713
+ class ACLReadableByRoleConstraint < Constraint
2714
+ # @!method readable_by_role
2715
+ # @example
2716
+ # q.where :ACL.readable_by_role => "Admin"
2717
+ # @return [ACLReadableByRoleConstraint]
2718
+ register :readable_by_role
2719
+
2720
+ # @return [Hash] the compiled constraint using _rperm field.
2721
+ def build
2722
+ permissions = ACLPermissions.collect_role_only(@value)
2723
+ ACLPermissions.pipeline(permissions, field: "_rperm")
2724
+ end
2725
+ end
2726
+
2727
+ # A constraint for filtering objects based on ACL write permissions.
2728
+ # This constraint queries the MongoDB _wperm field directly.
2729
+ # Strings are used as exact permission values (user IDs or "role:RoleName" format).
2730
+ #
2731
+ # For role-based filtering with automatic "role:" prefix, use writable_by_role instead.
2732
+ #
2733
+ # # Find objects writable by a specific user object (fetches user's roles automatically)
2734
+ # Post.where(:ACL.writable_by => user)
2735
+ #
2736
+ # # Find objects writable by exact permission strings (no prefix added)
2737
+ # Post.where(:ACL.writable_by => "user123") # User ID
2738
+ # Post.where(:ACL.writable_by => "role:Admin") # Role with explicit prefix
2739
+ # Post.where(:ACL.writable_by => ["user123", "role:Admin"])
2740
+ #
2741
+ class ACLWritableByConstraint < Constraint
2742
+ # @!method writable_by
2743
+ # A registered method on a symbol to create the constraint.
2744
+ # @example
2745
+ # q.where :ACL.writable_by => user_or_permission_strings
2746
+ # @return [ACLWritableByConstraint]
2747
+ register :writable_by
2748
+
2749
+ # @return [Hash] the compiled constraint using _wperm field.
2750
+ def build
2751
+ # Use @value directly to preserve type information before
2752
+ # formatted_value converts to pointers.
2753
+ value = @value
2754
+
2755
+ # Special case: "none" matches objects whose _wperm is an empty
2756
+ # array — master-key-only documents. See {ACLReadableByConstraint#build}.
2757
+ if value.is_a?(String) && value == "none"
2758
+ pipeline = [{ "$match" => { "_wperm" => { "$eq" => [] } } }]
2759
+ return { "__aggregation_pipeline" => pipeline }
2760
+ end
2761
+
2762
+ permissions = ACLPermissions.collect(value)
2763
+ ACLPermissions.pipeline(permissions, field: "_wperm")
2764
+ end
2765
+ end
2766
+
2767
+ # A constraint for filtering objects writable by specific role names.
2768
+ # Automatically adds "role:" prefix to role names.
2769
+ #
2770
+ # # Find objects writable by Admin role (string - adds role: prefix)
2771
+ # Post.where(:ACL.writable_by_role => "Admin")
2772
+ #
2773
+ # # Find objects writable by Role object
2774
+ # Post.where(:ACL.writable_by_role => admin_role)
2775
+ #
2776
+ # # Find objects writable by multiple roles
2777
+ # Post.where(:ACL.writable_by_role => ["Admin", "Moderator"])
2778
+ #
2779
+ class ACLWritableByRoleConstraint < Constraint
2780
+ # @!method writable_by_role
2781
+ # @example
2782
+ # q.where :ACL.writable_by_role => "Admin"
2783
+ # @return [ACLWritableByRoleConstraint]
2784
+ register :writable_by_role
2785
+
2786
+ # @return [Hash] the compiled constraint using _wperm field.
2787
+ def build
2788
+ permissions = ACLPermissions.collect_role_only(@value)
2789
+ ACLPermissions.pipeline(permissions, field: "_wperm")
2790
+ end
2791
+ end
2792
+
2793
+ # A constraint for comparing pointer fields through linked objects using MongoDB aggregation.
2794
+ # This allows comparing ObjectA.field1 with ObjectA.linkedObject.field2 where both are pointers.
2795
+ #
2796
+ # # Find ObjectA where ObjectA.author equals ObjectA.project.owner
2797
+ # ObjectA.where(:author.equals_linked_pointer => { through: :project, field: :owner })
2798
+ #
2799
+ # # This generates a MongoDB aggregation pipeline with $lookup and $expr
2800
+ # # to compare pointer fields across linked documents
2801
+ #
2802
+ class PointerEqualsLinkedPointerConstraint < Constraint
2803
+ # @!method equals_linked_pointer
2804
+ # A registered method on a symbol to create the constraint.
2805
+ # @example
2806
+ # q.where :field.equals_linked_pointer => { through: :linked_field, field: :target_field }
2807
+ # @return [PointerEqualsLinkedPointerConstraint]
2808
+ register :equals_linked_pointer
2809
+
2810
+ # @return [Hash] the compiled constraint.
2811
+ def build
2812
+ unless @value.is_a?(Hash) && @value[:through] && @value[:field]
2813
+ raise ArgumentError, "equals_linked_pointer requires: { through: :linked_field, field: :target_field }"
2814
+ end
2815
+
2816
+ through_field = @value[:through]
2817
+ target_field = @value[:field]
2818
+ local_field = @operation.operand
2819
+
2820
+ # Format field names according to Parse conventions
2821
+ # Pointer fields in MongoDB are stored with _p_ prefix
2822
+ formatted_through = "_p_" + Parse::Query.format_field(through_field)
2823
+ formatted_target = "_p_" + Parse::Query.format_field(target_field)
2824
+ formatted_local = "_p_" + Parse::Query.format_field(local_field)
2825
+
2826
+ # Determine the target collection name from the through field
2827
+ # Use classify to convert field name to class name (e.g., :project -> "Project")
2828
+ target_collection = through_field.to_s.classify
2829
+
2830
+ # Build the aggregation pipeline
2831
+ # Use clean alias name without _p_ prefix for readability
2832
+ lookup_alias = "#{through_field.to_s.camelize(:lower)}_data"
2833
+
2834
+ # Parse stores pointers as "ClassName$objectId" strings
2835
+ # We need to extract just the objectId part after the $
2836
+ pipeline = [
2837
+ {
2838
+ "$addFields" => {
2839
+ "#{formatted_through}_id" => {
2840
+ "$substr" => [
2841
+ "$#{formatted_through}",
2842
+ target_collection.length + 1, # Skip "ClassName$"
2843
+ -1, # Rest of string
2844
+ ],
2845
+ },
2846
+ },
2847
+ },
2848
+ {
2849
+ "$lookup" => {
2850
+ "from" => target_collection,
2851
+ "localField" => formatted_through,
2852
+ "foreignField" => "_id",
2853
+ "as" => lookup_alias,
2854
+ },
2855
+ },
2856
+ {
2857
+ "$match" => {
2858
+ "$expr" => {
2859
+ "$eq" => [
2860
+ { "$arrayElemAt" => ["$#{lookup_alias}.#{formatted_target}", 0] },
2861
+ "$#{formatted_local}",
2862
+ ],
2863
+ },
2864
+ },
2865
+ },
2866
+ ]
2867
+
2868
+ # Return a special marker that indicates this needs aggregation pipeline processing
2869
+ { "__aggregation_pipeline" => pipeline }
2870
+ end
2871
+ end
2872
+
2873
+ # Constraint for comparing pointer fields where they do NOT equal through linked objects.
2874
+ # Uses MongoDB's $lookup to join collections and $expr with $ne to compare fields.
2875
+ #
2876
+ # Usage:
2877
+ # Asset.where(:project.does_not_equal_linked_pointer => { through: :capture, field: :project })
2878
+ #
2879
+ # This generates a MongoDB aggregation pipeline that:
2880
+ # 1. Uses $lookup to join the linked collection
2881
+ # 2. Uses $match with $expr and $ne to find records where fields do NOT match
2882
+ #
2883
+ # @example Find assets where the project does not equal the capture's project
2884
+ # Asset.where(:project.does_not_equal_linked_pointer => {
2885
+ # through: :capture,
2886
+ # field: :project
2887
+ # })
2888
+ class DoesNotEqualLinkedPointerConstraint < Constraint
2889
+ register :does_not_equal_linked_pointer
2890
+
2891
+ # Builds the MongoDB aggregation pipeline for the does-not-equal-linked-pointer constraint
2892
+ # @return [Hash] Hash containing the aggregation pipeline
2893
+ # @raise [ArgumentError] if required parameters are missing or invalid
2894
+ def build
2895
+ # Validate that value is a hash with required keys
2896
+ unless @value.is_a?(Hash) && @value[:through] && @value[:field]
2897
+ raise ArgumentError, "DoesNotEqualLinkedPointerConstraint requires a hash with :through and :field keys"
2898
+ end
2899
+
2900
+ through_field = @value[:through]
2901
+ target_field = @value[:field]
2902
+
2903
+ # Convert field names to Parse format (snake_case to camelCase) with _p_ prefix for pointers
2904
+ local_field_name = format_field_name(@operation.operand, is_pointer: true)
2905
+ through_field_name = format_field_name(through_field, is_pointer: true)
2906
+ target_field_name = format_field_name(target_field, is_pointer: true)
2907
+
2908
+ # Determine the collection name for the lookup (Rails pluralization)
2909
+ through_class_name = through_field.to_s.classify
2910
+ lookup_collection = through_class_name
2911
+
2912
+ # Generate unique alias name for the joined data (use clean name without _p_ prefix)
2913
+ lookup_alias = "#{through_field.to_s.camelize(:lower)}_data"
2914
+
2915
+ # Build the MongoDB aggregation pipeline
2916
+ pipeline = []
2917
+
2918
+ # Parse stores pointers as "ClassName$objectId" strings
2919
+ # We need to extract just the objectId part after the $
2920
+ # Stage 1: Add field with extracted objectId
2921
+ add_fields_stage = {
2922
+ "$addFields" => {
2923
+ "#{through_field_name}_id" => {
2924
+ "$substr" => [
2925
+ "$#{through_field_name}",
2926
+ lookup_collection.length + 1, # Skip "ClassName$"
2927
+ -1, # Rest of string
2928
+ ],
2929
+ },
2930
+ },
2931
+ }
2932
+ pipeline << add_fields_stage
2933
+
2934
+ # Stage 2: $lookup to join the linked collection
2935
+ lookup_stage = {
2936
+ "$lookup" => {
2937
+ "from" => lookup_collection,
2938
+ "localField" => through_field_name,
2939
+ "foreignField" => "_id",
2940
+ "as" => lookup_alias,
2941
+ },
2942
+ }
2943
+ pipeline << lookup_stage
2944
+
2945
+ # Stage 2: $match with $expr to compare the fields using $ne (not equal)
2946
+ match_stage = {
2947
+ "$match" => {
2948
+ "$expr" => {
2949
+ "$ne" => [
2950
+ { "$arrayElemAt" => ["$#{lookup_alias}.#{target_field_name}", 0] },
2951
+ "$#{local_field_name}",
2952
+ ],
2953
+ },
2954
+ },
2955
+ }
2956
+ pipeline << match_stage
2957
+
2958
+ # Return a special marker that indicates this needs aggregation pipeline processing
2959
+ { "__aggregation_pipeline" => pipeline }
2960
+ end
2961
+
2962
+ private
2963
+
2964
+ # Converts field names from snake_case to camelCase for Parse Server compatibility
2965
+ # and adds _p_ prefix for pointer fields in MongoDB
2966
+ # @param field [Symbol, String] the field name to format
2967
+ # @param is_pointer [Boolean] whether this field is a pointer field
2968
+ # @return [String] the formatted field name
2969
+ def format_field_name(field, is_pointer: true)
2970
+ formatted = field.to_s.camelize(:lower)
2971
+ # Add _p_ prefix for pointer fields as they're stored that way in MongoDB
2972
+ is_pointer ? "_p_#{formatted}" : formatted
2973
+ end
2974
+ end
2975
+
2976
+ # Shared helper module for ACL constraint classes.
2977
+ # Provides common normalization logic for converting various input types
2978
+ # (User, Role, Pointer, symbols, strings) to ACL permission keys.
2979
+ # @api private
2980
+ module AclConstraintHelpers
2981
+ private
2982
+
2983
+ # Normalize various input types to ACL permission keys.
2984
+ # @param value [Array, String, Symbol, Parse::User, Parse::Role, nil]
2985
+ # @return [Array<String>] normalized permission keys
2986
+ # @note Returns empty array for nil, [], "none", or :none (indicating no permissions)
2987
+ def normalize_acl_keys(value)
2988
+ # Handle special "none" case for no permissions
2989
+ return [] if value.nil?
2990
+ return [] if value == "none" || value == :none
2991
+ return [] if value.is_a?(Array) && value.empty?
2992
+
2993
+ Array(value).map do |item|
2994
+ case item
2995
+ when Parse::User
2996
+ item.id
2997
+ when Parse::Role
2998
+ "role:#{item.name}"
2999
+ when Parse::Pointer
3000
+ item.id
3001
+ when :public, :everyone, :world
3002
+ "*"
3003
+ when "public", "*"
3004
+ "*"
3005
+ when "none", :none
3006
+ nil # Will be compacted out, but array will be non-empty so won't match "no permissions"
3007
+ when String
3008
+ item
3009
+ when Symbol
3010
+ item == :public ? "*" : item.to_s
3011
+ else
3012
+ item.respond_to?(:id) ? item.id : item.to_s
3013
+ end
3014
+ end.compact.uniq
3015
+ end
3016
+ end
3017
+
3018
+ # ACL Read Permission Query Constraint
3019
+ # Query objects based on read permissions using MongoDB's internal _rperm field.
3020
+ # Parse Server restricts direct queries on _rperm, so this uses aggregation pipeline.
3021
+ #
3022
+ # @example Find objects with NO read permissions (master key only / private)
3023
+ # Song.query.where(:acl.readable_by => [])
3024
+ #
3025
+ # @example Find objects readable by a specific user ID
3026
+ # Song.query.where(:acl.readable_by => "userId123")
3027
+ # Song.query.where(:acl.readable_by => current_user)
3028
+ #
3029
+ # @example Find objects readable by a role
3030
+ # Song.query.where(:acl.readable_by => "role:Admin")
3031
+ #
3032
+ # @example Find objects with public read access
3033
+ # Song.query.where(:acl.readable_by => "*")
3034
+ # Song.query.where(:acl.readable_by => :public)
3035
+ #
3036
+ # @example Find objects readable by ANY of the specified users/roles
3037
+ # Song.query.where(:acl.readable_by => [user1.id, "role:Admin", "*"])
3038
+ #
3039
+ # @note This constraint uses aggregation pipeline because Parse Server
3040
+ # restricts direct queries on the internal _rperm field.
3041
+ class ReadableByConstraint < Constraint
3042
+ include AclConstraintHelpers
3043
+
3044
+ # @!method readable_by
3045
+ # A registered method on a symbol to create the constraint.
3046
+ # @example
3047
+ # q.where :acl.readable_by => []
3048
+ # q.where :acl.readable_by => "userId"
3049
+ # q.where :acl.readable_by => ["userId", "role:Admin"]
3050
+ # @return [ReadableByConstraint]
3051
+ # NOTE: :readable_by is already registered by ACLReadableByConstraint above.
3052
+ # This class provides simplified empty ACL queries and is used internally.
3053
+
3054
+ # @return [Hash] the compiled constraint using aggregation pipeline.
3055
+ def build
3056
+ keys = normalize_acl_keys(@value)
3057
+
3058
+ if keys.empty?
3059
+ # Empty array = no read permissions (master key only)
3060
+ # Match documents where _rperm is an empty array
3061
+ pipeline = [
3062
+ {
3063
+ "$match" => {
3064
+ "$or" => [
3065
+ { "_rperm" => { "$exists" => true, "$eq" => [] } },
3066
+ { "_rperm" => { "$exists" => false } },
3067
+ ],
3068
+ },
3069
+ },
3070
+ ]
3071
+ else
3072
+ # Find objects readable by ANY of the specified keys
3073
+ # Use $in to match if _rperm contains any of the keys
3074
+ pipeline = [
3075
+ {
3076
+ "$match" => {
3077
+ "_rperm" => { "$in" => keys },
3078
+ },
3079
+ },
3080
+ ]
3081
+ end
3082
+
3083
+ { "__aggregation_pipeline" => pipeline }
3084
+ end
3085
+ end
3086
+
3087
+ # ACL Write Permission Query Constraint
3088
+ # Query objects based on write permissions using MongoDB's internal _wperm field.
3089
+ # Parse Server restricts direct queries on _wperm, so this uses aggregation pipeline.
3090
+ #
3091
+ # @example Find objects with NO write permissions (master key only / read-only)
3092
+ # Song.query.where(:acl.writeable_by => [])
3093
+ #
3094
+ # @example Find objects writable by a specific user ID
3095
+ # Song.query.where(:acl.writeable_by => "userId123")
3096
+ # Song.query.where(:acl.writeable_by => current_user)
3097
+ #
3098
+ # @example Find objects writable by a role
3099
+ # Song.query.where(:acl.writeable_by => "role:Admin")
3100
+ #
3101
+ # @note This constraint uses aggregation pipeline because Parse Server
3102
+ # restricts direct queries on the internal _wperm field.
3103
+ class WriteableByConstraint < Constraint
3104
+ include AclConstraintHelpers
3105
+
3106
+ # @!method writeable_by
3107
+ # A registered method on a symbol to create the constraint.
3108
+ # @example
3109
+ # q.where :acl.writeable_by => []
3110
+ # q.where :acl.writeable_by => "userId"
3111
+ # @return [WriteableByConstraint]
3112
+ register :writeable_by
3113
+
3114
+ # @return [Hash] the compiled constraint using aggregation pipeline.
3115
+ def build
3116
+ keys = normalize_acl_keys(@value)
3117
+
3118
+ if keys.empty?
3119
+ # Empty array = no write permissions (master key only)
3120
+ pipeline = [
3121
+ {
3122
+ "$match" => {
3123
+ "$or" => [
3124
+ { "_wperm" => { "$exists" => true, "$eq" => [] } },
3125
+ { "_wperm" => { "$exists" => false } },
3126
+ ],
3127
+ },
3128
+ },
3129
+ ]
3130
+ else
3131
+ # Find objects writable by ANY of the specified keys
3132
+ pipeline = [
3133
+ {
3134
+ "$match" => {
3135
+ "_wperm" => { "$in" => keys },
3136
+ },
3137
+ },
3138
+ ]
3139
+ end
3140
+
3141
+ { "__aggregation_pipeline" => pipeline }
3142
+ end
3143
+ end
3144
+
3145
+ # Alias for writeable_by (American spelling)
3146
+ # NOTE: :writable_by is already registered by ACLWritableByConstraint above.
3147
+ # This class provides simplified empty ACL queries and is used internally.
3148
+ class WritableByConstraint < WriteableByConstraint
3149
+ end
3150
+
3151
+ # ACL NOT Readable By Constraint
3152
+ # Query objects that are NOT readable by the specified users/roles.
3153
+ # Useful for finding objects hidden from specific users.
3154
+ #
3155
+ # @example Find objects NOT readable by a user (hidden from them)
3156
+ # Song.query.where(:acl.not_readable_by => current_user)
3157
+ #
3158
+ # @example Find objects NOT publicly readable
3159
+ # Song.query.where(:acl.not_readable_by => "*")
3160
+ # Song.query.where(:acl.not_readable_by => :public)
3161
+ #
3162
+ # @note This constraint uses aggregation pipeline because Parse Server
3163
+ # restricts direct queries on the internal _rperm field.
3164
+ class NotReadableByConstraint < Constraint
3165
+ include AclConstraintHelpers
3166
+
3167
+ register :not_readable_by
3168
+
3169
+ def build
3170
+ keys = normalize_acl_keys(@value)
3171
+ return { "__aggregation_pipeline" => [] } if keys.empty?
3172
+
3173
+ # Find objects where _rperm does NOT contain any of the keys
3174
+ pipeline = [
3175
+ {
3176
+ "$match" => {
3177
+ "_rperm" => { "$nin" => keys },
3178
+ },
3179
+ },
3180
+ ]
3181
+
3182
+ { "__aggregation_pipeline" => pipeline }
3183
+ end
3184
+ end
3185
+
3186
+ # ACL NOT Writable By Constraint
3187
+ # Query objects that are NOT writable by the specified users/roles.
3188
+ #
3189
+ # @example Find objects NOT writable by a user
3190
+ # Song.query.where(:acl.not_writeable_by => current_user)
3191
+ #
3192
+ # @note This constraint uses aggregation pipeline because Parse Server
3193
+ # restricts direct queries on the internal _wperm field.
3194
+ class NotWriteableByConstraint < Constraint
3195
+ include AclConstraintHelpers
3196
+
3197
+ register :not_writeable_by
3198
+
3199
+ def build
3200
+ keys = normalize_acl_keys(@value)
3201
+ return { "__aggregation_pipeline" => [] } if keys.empty?
3202
+
3203
+ pipeline = [
3204
+ {
3205
+ "$match" => {
3206
+ "_wperm" => { "$nin" => keys },
3207
+ },
3208
+ },
3209
+ ]
3210
+
3211
+ { "__aggregation_pipeline" => pipeline }
3212
+ end
3213
+ end
3214
+
3215
+ # Alias for not_writeable_by (American spelling)
3216
+ class NotWritableByConstraint < NotWriteableByConstraint
3217
+ register :not_writable_by
3218
+ end
3219
+
3220
+ # ACL Private/Master-Key-Only Constraint
3221
+ # Query objects with completely empty ACL (no read or write permissions).
3222
+ # These objects can only be accessed with the master key.
3223
+ #
3224
+ # @example Find all private objects
3225
+ # Song.query.where(:acl.private_acl => true)
3226
+ #
3227
+ # @example Find all non-private objects (have some permissions)
3228
+ # Song.query.where(:acl.private_acl => false)
3229
+ #
3230
+ # @note This constraint uses aggregation pipeline because Parse Server
3231
+ # restricts direct queries on internal ACL fields.
3232
+ class PrivateAclConstraint < Constraint
3233
+ register :private_acl
3234
+ register :master_key_only
3235
+
3236
+ def build
3237
+ is_private = @value == true
3238
+
3239
+ if is_private
3240
+ # Match objects with empty or missing _rperm AND _wperm
3241
+ pipeline = [
3242
+ {
3243
+ "$match" => {
3244
+ "$and" => [
3245
+ {
3246
+ "$or" => [
3247
+ { "_rperm" => { "$exists" => true, "$eq" => [] } },
3248
+ { "_rperm" => { "$exists" => false } },
3249
+ ],
3250
+ },
3251
+ {
3252
+ "$or" => [
3253
+ { "_wperm" => { "$exists" => true, "$eq" => [] } },
3254
+ { "_wperm" => { "$exists" => false } },
3255
+ ],
3256
+ },
3257
+ ],
3258
+ },
3259
+ },
3260
+ ]
3261
+ else
3262
+ # Match objects that have SOME permissions (either read or write)
3263
+ pipeline = [
3264
+ {
3265
+ "$match" => {
3266
+ "$or" => [
3267
+ { "_rperm" => { "$exists" => true, "$ne" => [] } },
3268
+ { "_wperm" => { "$exists" => true, "$ne" => [] } },
3269
+ ],
3270
+ },
3271
+ },
3272
+ ]
3273
+ end
3274
+
3275
+ { "__aggregation_pipeline" => pipeline }
3276
+ end
3277
+ end
3278
+ end
3279
+ end