parse-stack-next 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,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
|