parse-stack-next 5.7.1 → 5.7.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cb6a2c813f0a1b2d624852680651ed289f7aac282fa24e246b28d0ed9e69383
4
- data.tar.gz: a3984ae30555af28eadfdc19fa936103bb252bad332b0ddb8ead163277ae4bde
3
+ metadata.gz: b773e88a55f8771a7d898978652e9be673ec496627fcbaa0b0eb68b950486812
4
+ data.tar.gz: 7813d865cb6cc09b6c44070186dbf67383304d9407a5de58fdbb723cce4dc081
5
5
  SHA512:
6
- metadata.gz: d19428000255676b8a5f10ee0087bf3b6d21216c499f56d895d1ee50a646985a4297b725807b2b19878a8576415c8e1551471d34fa236e9bf90170e6e13abed5
7
- data.tar.gz: 5db3159d3da214300df9e21760ae871f070f6a79d961fe9e082b5160dd0bd2c07cba5d64656791b961ab2c347e9ebf5151eb607fcf0a3768587a3c382476000e
6
+ metadata.gz: 1ffa5266044c369c673c8c5e4e57d4b37d882674eae6d8f7c79f3b1a8318a75dbd76d97036337d8a5416cfdcc022c1db3353e20de53857eaf8e37ac29913dfa7
7
+ data.tar.gz: e897bb63c7030751cbb2a9720e6fde91e23f6c118a6656e9063a84df4ec7305db1e3f8dd141e600f2e9765fb3660fdd26b0c36c2c321184c9743c6be976498c9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.2
4
+
5
+ #### `between` accepts Ruby Range values
6
+
7
+ - **NEW**: The `between` constraint now accepts a Ruby `Range` in addition to
8
+ a 2-element array, so `Person.where(:age.between => 5..25)` and
9
+ `Record.where(:date.between => 5.days.ago...2.days.ago)` work directly. An
10
+ inclusive range (`..`) maps its upper bound to `$lte`, matching the existing
11
+ array form, while an exclusive range (`...`) maps it to `$lt` instead.
12
+ Beginless (`..25`) and endless (`5..`) ranges are also supported and
13
+ constrain only the side that is present, so `Person.where(:age.between =>
14
+ 18..)` compiles to `{"$gte" => 18}` with no upper bound. The array form is
15
+ unchanged, and both forms produce identical output for the same bounds.
16
+
17
+ #### `Query#where_not_between` for the negated form of a range
18
+
19
+ - **NEW**: `Query#where_not_between(field, value)` adds the logical negation
20
+ of `between`: `Person.query.where_not_between(:age, 5..25)` compiles to
21
+ `age < 5 OR age > 25`, accepting the same Range and 2-element Array forms
22
+ as `between` (exclusive ranges flip the upper side to `$gte`, and a
23
+ beginless or endless Range negates to a single one-sided comparison with
24
+ no `$or` needed). It is not available as a `field.not_between => value`
25
+ symbol constraint: a range's negation is inherently an `$or` of two
26
+ comparisons, and only one `$or` group can be safely merged into a
27
+ compiled query, so a symbol constraint that unilaterally emitted one
28
+ could silently collide with an existing `$or` from `or_where`/`|`.
29
+ `where_not_between` instead composes the negation the way
30
+ `Parse::Query.and` already does, so it correctly nests inside a query's
31
+ other `.where` conditions instead of replacing them, and raises
32
+ `ArgumentError` if the query already has an `$or` group rather than
33
+ silently dropping part of it.
34
+
35
+ #### `embed_image` forwards a presigned URL when the source file has one
36
+
37
+ - **FIXED**: `embed_image` always sent the source file's bare `file.url` to
38
+ the embedding provider (or to the SDK's own `:bytes`-mode downloader). On a
39
+ private-bucket file adapter (S3/GCS configured with `presignedUrl: true`),
40
+ `file.url` is the canonical URL with its signature stripped, so the
41
+ provider's fetch (or the SDK's download) got a 403 instead of the image.
42
+ `Parse::File` already captures the signed variant in `file.presigned_url`
43
+ whenever Parse Server returns one, but `embed_image` never read it.
44
+ Recompute now forwards `file.presigned_url` when it is present and not yet
45
+ expired, and falls back to the bare URL otherwise, for both `source: :url`
46
+ and `source: :bytes`. The stored digest is still keyed on the bare
47
+ canonical URL, so a save that only rotates the file's signature does not
48
+ trigger a needless re-embed. The validity check ignores
49
+ `presigned_url_valid?`'s default 60-second safety buffer (meant for a
50
+ browser render, not an immediate server-side fetch), since on a
51
+ private-bucket adapter the fallback URL is not fetchable at all and would
52
+ otherwise 403 for the last minute of every signature's life.
53
+ `Parse::Embeddings::ImageFetch::FetchedImage#url` now stores the
54
+ query-stripped URL rather than the presigned one, since a live signature
55
+ has no reason to survive into that value object's `#inspect` output.
56
+ `source: :url` mode can now forward a presigned URL to the embedding
57
+ provider under the same `Parse::Embeddings.trust_provider_url_fetch`
58
+ consent already required to forward any URL; operators relying on private
59
+ buckets should confirm the provider's egress handling covers
60
+ credential-bearing URLs, not just public ones.
61
+
62
+ #### `Query#get` now resolves aliased `parse_class` names correctly
63
+
64
+ - **FIXED**: `Query#get` looked up the target class with a raw
65
+ `Object.const_get(@table)`, which only worked when the Parse class name
66
+ matched the Ruby constant name exactly. A model that renames its table via
67
+ `parse_class "SomeOtherName"` was never found by this lookup, so `get`
68
+ silently fell back to a generic `Parse::Object`/`Parse::Pointer` instead of
69
+ hydrating the declared model. `Query#get` now passes the table name through
70
+ to `Parse::Object.build` as a string, letting it run its own
71
+ `Parse::Model.find_class` resolution, which already understands
72
+ `parse_class` aliasing.
73
+
74
+ #### `_safe_warn` now writes through a configured logger
75
+
76
+ - **FIXED**: Internal warnings for authentication, timeout, and cloud-code
77
+ errors (`Parse::Client._safe_warn`) always wrote to STDERR, even when an
78
+ app had configured `Parse.logger = Rails.logger` (or any other logger) for
79
+ the rest of its Parse request/response logging. These warnings now route
80
+ through `Parse::Middleware::Logging.logger` when one is configured, so they
81
+ land in the same place as the app's other logs; STDERR remains the fallback
82
+ when no logger is configured, matching prior behavior. Every call site
83
+ raises the corresponding typed `Parse::Error` immediately after this
84
+ warning, so a configured logger that itself raises (a closed handle, a
85
+ full disk, a remote-aggregator client erroring on a socket) now falls back
86
+ to STDERR rather than propagating in place of the real error and masking
87
+ it.
88
+
3
89
  ### 5.7.1
4
90
 
5
91
  #### Cache-invalidation webhooks no longer break every application hook for the same trigger
@@ -191,7 +191,7 @@ module Parse
191
191
 
192
192
  result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
193
193
  { status: result_hash[:status], body: result_hash[:body] }
194
- rescue Parse::Agent::Unauthorized => e
194
+ rescue Parse::Agent::Unauthorized
195
195
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
196
196
  rescue StandardError => e
197
197
  # Do not leak the exception class name (gem fingerprinting). Server-
@@ -295,7 +295,7 @@ module Parse
295
295
  else
296
296
  { status: 200, body: jsonrpc_envelope(id, result: result) }
297
297
  end
298
- rescue Parse::Agent::Unauthorized => e
298
+ rescue Parse::Agent::Unauthorized
299
299
  { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
300
300
  rescue Parse::Agent::AccessDenied
301
301
  # Class-authorization denial (agent_hidden / classes: allowlist), e.g.
data/lib/parse/agent.rb CHANGED
@@ -2475,7 +2475,6 @@ module Parse
2475
2475
  end
2476
2476
 
2477
2477
  ActiveSupport::Notifications.instrument("parse.agent.tool_call", payload) do
2478
- response = nil
2479
2478
  # Install a fresh embedding accumulator for this tool span. The
2480
2479
  # process-wide "parse.embeddings.embed" subscriber records each
2481
2480
  # embed into it; the ensure below reads + restores it so the
@@ -17,12 +17,24 @@ module Parse
17
17
  # @!attribute [rw] body
18
18
  # @return [Hash] the body of this request.
19
19
 
20
- # TODO: Document opts and cache options.
21
-
22
20
  # @!attribute [rw] opts
23
- # @return [Hash] a set of options for this request.
21
+ # @return [Hash] per-request options consumed by {Parse::Client#request}
22
+ # when it builds the HTTP headers for this request. Recognized keys:
23
+ # * `:cache` — `false` sends `Cache-Control: no-cache`; `:write_only`
24
+ # skips the cache read but still writes the response; a `Numeric`
25
+ # overrides the cache expiration (seconds) for this request only.
26
+ # * `:use_master_key` — `false` forces the master key off for this
27
+ # request even if the client has one configured.
28
+ # * `:session_token` — a session token to authenticate this request as
29
+ # a specific user, bypassing the client's default auth context.
30
+ # * `:idempotent` — explicitly enables/disables idempotency-header
31
+ # generation for this request, overriding the class-level defaults.
32
+ # * `:request_id` — a caller-supplied idempotency key; see
33
+ # {.enable_idempotency!}.
24
34
  # @!attribute [rw] cache
25
- # @return [Boolean]
35
+ # @return [Boolean] unused by {Parse::Request} itself; retained as a
36
+ # plain accessor for callers that stash a cache handle or flag
37
+ # directly on the request object rather than through `opts[:cache]`.
26
38
  attr_accessor :method, :path, :body, :headers, :opts, :cache
27
39
 
28
40
  # @!visibility private
data/lib/parse/client.rb CHANGED
@@ -505,23 +505,44 @@ module Parse
505
505
  end
506
506
 
507
507
  # @!visibility private
508
- # Emit a redacted warning about a Parse::Response error to stderr.
508
+ # Emit a redacted warning about a Parse::Response error.
509
509
  #
510
510
  # Routes the response error string through
511
511
  # {Parse::Middleware::BodyBuilder.redact} to strip credentials (passwords,
512
512
  # tokens, sessionTokens, access_tokens, authData) before logging, and
513
513
  # truncates to {SAFE_WARN_MAX_ERROR_LENGTH} chars.
514
514
  #
515
+ # Writes through {Parse::Middleware::Logging.logger} when the app has
516
+ # configured one (`Parse.logger = ...`), so these warnings land wherever
517
+ # the rest of the app's Parse request/response logging goes instead of
518
+ # bypassing it. Falls back to plain `warn` (STDERR) when no logger is
519
+ # configured, matching prior behavior. Every call site immediately
520
+ # raises the corresponding typed {Parse::Error} right after calling
521
+ # this method, so a misbehaving app-supplied logger (closed handle,
522
+ # full disk, a remote-aggregator client that raises on socket error)
523
+ # must not be allowed to propagate in its place and mask the real
524
+ # error — falls back to `warn` if the logger itself raises.
525
+ #
515
526
  # @param tag [String] the bracketed prefix (e.g. "AuthenticationError").
516
527
  # @param response [Parse::Response] the response carrying the error.
517
528
  # @param name [String, nil] optional cloud-function or job name for context.
518
529
  # @return [nil]
519
530
  def _safe_warn(tag, response, name: nil)
520
531
  err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH]
521
- if name
522
- warn "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
532
+ msg = if name
533
+ "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
534
+ else
535
+ "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
536
+ end
537
+ logger = Parse::Middleware::Logging.logger
538
+ if logger
539
+ begin
540
+ logger.warn(msg)
541
+ rescue StandardError
542
+ warn msg
543
+ end
523
544
  else
524
- warn "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
545
+ warn msg
525
546
  end
526
547
  nil
527
548
  end
@@ -164,7 +164,15 @@ module Parse
164
164
 
165
165
  mime = verify!(bytes, url: canonical)
166
166
  bytes = strip_metadata(bytes, mime) if exif_strip
167
- FetchedImage.new(bytes: bytes, mime_type: mime, url: canonical)
167
+ # Store the query-stripped URL, not `canonical` verbatim: when
168
+ # `url` is a presigned URL (a private-bucket file adapter),
169
+ # `canonical` carries a live signature, and FetchedImage#url is
170
+ # purely informational from here on (nothing re-fetches it).
171
+ # Keeping the signature out of the struct preserves the
172
+ # log-safety `#inspect` below was written for — third-party
173
+ # provider adapters and error reporters that capture locals
174
+ # would otherwise leak a valid bearer credential through it.
175
+ FetchedImage.new(bytes: bytes, mime_type: mime, url: Parse::File.strip_query(canonical))
168
176
  end
169
177
 
170
178
  # Verify raw bytes: sniff the magic, check the allowlist, and
@@ -382,6 +382,17 @@ module Parse
382
382
  # `"true"`, or a non-matching String) raises
383
383
  # {ConfirmationRequired}. Reset to `nil` to disable.
384
384
  #
385
+ # For a `:file` source backed by a private-bucket adapter
386
+ # (S3/GCS with server-side presigning), the URL forwarded under
387
+ # this sentinel may be the file's presigned URL rather than its
388
+ # bare canonical one — a time-limited bearer credential for that
389
+ # object, not just a pointer to it (see {Parse::File#presigned_url}
390
+ # and `Parse::Core::EmbedManaged.embed_image`). Reviewing the
391
+ # provider's egress behavior before setting this sentinel should
392
+ # account for that: the provider (and anyone with access to its
393
+ # request logs) gains temporary read access to the object for
394
+ # however long the signature remains valid.
395
+ #
385
396
  # @param value [String, nil] {TRUST_PROVIDER_URL_FETCH_SENTINEL} or nil.
386
397
  # @raise [ConfirmationRequired] on any other value.
387
398
  def trust_provider_url_fetch=(value)
@@ -395,10 +406,12 @@ module Parse
395
406
  "String #{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect}. Plain `true` and " \
396
407
  "other values are refused — forwarding image URLs to a third-party " \
397
408
  "provider lets that provider issue an HTTP request from its own network " \
398
- "with attacker-controllable host/path. Set the sentinel only after you " \
399
- "have configured Parse::Embeddings.allowed_image_hosts AND reviewed the " \
400
- "provider's documented egress behavior (DNS rebinding window, redirect " \
401
- "policy)."
409
+ "with attacker-controllable host/path, and for a private-bucket file may " \
410
+ "hand it a time-limited presigned URL rather than a bare pointer. Set the " \
411
+ "sentinel only after you have configured " \
412
+ "Parse::Embeddings.allowed_image_hosts AND reviewed the provider's " \
413
+ "documented egress behavior (DNS rebinding window, redirect policy, " \
414
+ "request-log retention)."
402
415
  end
403
416
  CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = value }
404
417
  end
@@ -687,7 +687,7 @@ module Parse
687
687
  return if stored_digest == digest && target_present
688
688
 
689
689
  provider = Parse::Embeddings.provider(directive.provider_name)
690
- vectors = call_provider(provider, directive, input)
690
+ vectors = call_provider(provider, directive, input, record)
691
691
  unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
692
692
  raise Parse::Embeddings::InvalidResponseError,
693
693
  "Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \
@@ -774,16 +774,27 @@ module Parse
774
774
  # provider a {Parse::Embeddings::ImageFetch::FetchedImage}; `:url`
775
775
  # mode forwards the raw URL String (the provider validates and
776
776
  # fetches it itself).
777
- def self.call_provider(provider, directive, input)
777
+ #
778
+ # `input` is the bare canonical URL used for the digest (see
779
+ # {.build_source_input}). It stays stable across saves, so an
780
+ # unsigned re-read of the same file location does not force a
781
+ # re-embed.
782
+ # The actual fetch/forward target prefers the file's presigned
783
+ # URL ({Parse::File#presigned_url}) when one is currently valid,
784
+ # since a private-bucket adapter's bare `file.url` is stripped of
785
+ # its signature and will not resolve for the provider or for the
786
+ # SDK's own `:bytes`-mode download.
787
+ def self.call_provider(provider, directive, input, record)
778
788
  if directive.image?
789
+ fetch_url = presigned_fetch_url(record, directive, input)
779
790
  source = if directive.bytes_mode?
780
791
  Parse::Embeddings::ImageFetch.fetch!(
781
- input,
792
+ fetch_url,
782
793
  allow_insecure: directive.allow_insecure ? true : false,
783
794
  exif_strip: directive.exif_strip != false,
784
795
  )
785
796
  else
786
- input
797
+ fetch_url
787
798
  end
788
799
  provider.embed_image([source],
789
800
  input_type: directive.input_type,
@@ -793,6 +804,32 @@ module Parse
793
804
  end
794
805
  end
795
806
 
807
+ # @!visibility private
808
+ # Resolve the URL to actually fetch/forward for an image
809
+ # directive: the source file's currently-valid presigned URL if
810
+ # it has one, otherwise the bare canonical `fallback` (the same
811
+ # string used for the digest). Never used for text directives, so
812
+ # `directive.sources.first` is always a `:file` property here.
813
+ #
814
+ # Checks validity with a zero safety buffer rather than
815
+ # {Parse::File#presigned_url_valid?}'s default 60-second one. That
816
+ # default exists so a browser has time to render before a
817
+ # presigned URL goes stale; here it would instead spend the last
818
+ # 60 seconds of a perfectly usable presigned URL falling back to
819
+ # `fallback`, which on a private-bucket adapter is not fetchable
820
+ # at all. A fetch that starts immediately after this check has no
821
+ # meaningful use for that margin, and a 403 from a URL that
822
+ # expired mid-request is strictly better than a guaranteed 403
823
+ # from a URL known unfetchable in advance.
824
+ def self.presigned_fetch_url(record, directive, fallback)
825
+ file = record.public_send(directive.sources.first)
826
+ if file.respond_to?(:presigned_url_valid?) && file.presigned_url_valid?(buffer: 0)
827
+ file.presigned_url
828
+ else
829
+ fallback
830
+ end
831
+ end
832
+
796
833
  # @!visibility private
797
834
  # Concatenate source-field string values. `nil` and blank entries
798
835
  # are skipped; remaining values are joined with a double newline.
@@ -253,7 +253,6 @@ module Parse
253
253
  # same created_at date (down to the microsecond). This prevents getting the same
254
254
  # record in the next query request.
255
255
  exclusion_set = results.select { |r| r.created_at == next_cursor.created_at }.map(&:id)
256
- results = nil
257
256
  cursor = next_cursor
258
257
  end
259
258
  end
@@ -356,7 +355,6 @@ module Parse
356
355
  # Object.latest(:user.eq => user, limit: 5) # => 5 most recent for user
357
356
  # @return [Parse::Object] the most recently created object matching constraints.
358
357
  def latest(constraints = {})
359
- fetch_count = 1
360
358
  if constraints.is_a?(Numeric)
361
359
  fetch_count = constraints.to_i
362
360
  constraints = {}
@@ -385,7 +383,6 @@ module Parse
385
383
  # Object.last_updated(:user.eq => user, limit: 3) # => 3 most recently updated for user
386
384
  # @return [Parse::Object] the most recently updated object matching constraints.
387
385
  def last_updated(constraints = {})
388
- fetch_count = 1
389
386
  if constraints.is_a?(Numeric)
390
387
  fetch_count = constraints.to_i
391
388
  constraints = {}
@@ -600,7 +597,6 @@ module Parse
600
597
  parse_ids.compact!
601
598
  # determines if the result back to the call site is an array or a single result
602
599
  as_array = parse_ids.count > 1
603
- results = []
604
600
 
605
601
  # Default to write-only cache mode - find always gets fresh data
606
602
  # but updates cache for future cached reads. Controlled by feature flag.
@@ -1867,7 +1867,6 @@ module Parse
1867
1867
  # we should do a reverse lookup on who is registered for a different class type
1868
1868
  # than their name with parse_class
1869
1869
  klass = Parse::Model.find_class className
1870
- o = nil
1871
1870
  if klass.present?
1872
1871
  # when creating objects from Parse JSON data, don't use dirty tracking since
1873
1872
  # we are considering these objects as "pristine"
@@ -1561,7 +1561,6 @@ module Parse
1561
1561
 
1562
1562
  # if it's a hash, then it should be {:key=>"objectId", :query=>[]}
1563
1563
  remote_field_name = @operation.operand
1564
- query = nil
1565
1564
  if @value.is_a?(Hash)
1566
1565
  res = @value.symbolize_keys
1567
1566
  remote_field_name = res[:key] || remote_field_name
@@ -1613,7 +1612,6 @@ module Parse
1613
1612
 
1614
1613
  # if it's a hash, then it should be {:key=>"objectId", :query=>[]}
1615
1614
  remote_field_name = @operation.operand
1616
- query = nil
1617
1615
  if @value.is_a?(Hash)
1618
1616
  res = @value.symbolize_keys
1619
1617
  remote_field_name = res[:key] || remote_field_name
@@ -2263,7 +2261,6 @@ module Parse
2263
2261
  # @return [Hash] the compiled constraint.
2264
2262
  def build
2265
2263
  remote_field_name = @operation.operand
2266
- query = nil
2267
2264
 
2268
2265
  if @value.is_a?(Hash)
2269
2266
  res = @value.symbolize_keys
@@ -2314,7 +2311,6 @@ module Parse
2314
2311
  # @return [Hash] the compiled constraint.
2315
2312
  def build
2316
2313
  remote_field_name = @operation.operand
2317
- query = nil
2318
2314
 
2319
2315
  if @value.is_a?(Hash)
2320
2316
  res = @value.symbolize_keys
@@ -2516,6 +2512,19 @@ module Parse
2516
2512
  # User.where(:name.between => ["Alice", "John"])
2517
2513
  # # Generates: "name": { "$gte": "Alice", "$lte": "John" }
2518
2514
  #
2515
+ # # A Ruby Range works the same way as a 2-element array. An inclusive
2516
+ # # range (`..`) maps its end to $lte, while an exclusive range (`...`)
2517
+ # # maps its end to $lt.
2518
+ # User.where(:age.between => 18..65)
2519
+ # # Generates: "age": { "$gte": 18, "$lte": 65 }
2520
+ #
2521
+ # Record.where(:date.between => 5.days.ago...2.days.ago)
2522
+ # # Generates: "date": { "$gte": <5 days ago>, "$lt": <2 days ago> }
2523
+ #
2524
+ # # Beginless/endless ranges only constrain the side that is present.
2525
+ # User.where(:age.between => 18..)
2526
+ # # Generates: "age": { "$gte": 18 }
2527
+ #
2519
2528
  class BetweenConstraint < Constraint
2520
2529
  # @!method between
2521
2530
  # A registered method on a symbol to create the constraint.
@@ -2526,9 +2535,11 @@ module Parse
2526
2535
 
2527
2536
  # @return [Hash] the compiled constraint.
2528
2537
  def build
2538
+ return build_range(@value) if @value.is_a?(Range)
2539
+
2529
2540
  value = formatted_value
2530
2541
  unless value.is_a?(Array) && value.length == 2
2531
- raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [min_value, max_value]"
2542
+ raise ArgumentError, "#{self.class}: Value must be an array with exactly 2 elements [min_value, max_value], or a Range"
2532
2543
  end
2533
2544
 
2534
2545
  min_value, max_value = value
@@ -2542,6 +2553,51 @@ module Parse
2542
2553
  Parse::Constraint::LessThanOrEqualConstraint.key => formatted_max,
2543
2554
  } }
2544
2555
  end
2556
+
2557
+ # @!visibility private
2558
+ # Extract raw (unformatted) `[min_value, max_value, exclude_max]`
2559
+ # bounds from a `between`-style value: a Range (`exclude_max`
2560
+ # reflects `exclude_end?`) or a 2-element Array (always inclusive
2561
+ # on both ends, matching {#build}'s array branch). Shared with
2562
+ # {Parse::Query#where_not_between}, which needs the same bounds
2563
+ # to build the negated (`$or` of the two flipped comparisons)
2564
+ # form — a shape {BetweenConstraint} itself cannot safely emit
2565
+ # from a single constraint's `#build` (see `where_not_between`'s
2566
+ # docs for why).
2567
+ # @param value [Range, Array] a `between`-style value.
2568
+ # @return [Array(Object, Object, Boolean)]
2569
+ # @raise [ArgumentError] if `value` isn't a Range or 2-element Array.
2570
+ def self.extract_bounds(value)
2571
+ if value.is_a?(Range)
2572
+ [value.begin, value.end, value.exclude_end?]
2573
+ elsif value.is_a?(Array) && value.length == 2
2574
+ [value[0], value[1], false]
2575
+ else
2576
+ raise ArgumentError, "#{name}: Value must be an array with exactly 2 elements [min_value, max_value], or a Range"
2577
+ end
2578
+ end
2579
+
2580
+ private
2581
+
2582
+ # @return [Hash] the compiled constraint for a Ruby Range value.
2583
+ def build_range(range)
2584
+ bounds = {}
2585
+
2586
+ unless range.begin.nil?
2587
+ bounds[Parse::Constraint::GreaterThanOrEqualConstraint.key] = Parse::Constraint.formatted_value(range.begin)
2588
+ end
2589
+
2590
+ unless range.end.nil?
2591
+ upper_key = range.exclude_end? ? Parse::Constraint::LessThanConstraint.key : Parse::Constraint::LessThanOrEqualConstraint.key
2592
+ bounds[upper_key] = Parse::Constraint.formatted_value(range.end)
2593
+ end
2594
+
2595
+ if bounds.empty?
2596
+ raise ArgumentError, "#{self.class}: Range must have a begin, an end, or both (ex. 5.., ..25, 5..25)"
2597
+ end
2598
+
2599
+ { @operation.operand => bounds }
2600
+ end
2545
2601
  end
2546
2602
 
2547
2603
  # @!visibility private
data/lib/parse/query.rb CHANGED
@@ -1113,6 +1113,77 @@ module Parse
1113
1113
  copy_query
1114
1114
  end
1115
1115
 
1116
+ # Add a "field is NOT between" condition — the logical negation of
1117
+ # `.where(field.between => value)`: `field < min OR field > max` for a
1118
+ # fully-bounded Range/Array, or a single one-sided comparison when the
1119
+ # Range is beginless/endless (mirroring how {Parse::Constraint::BetweenConstraint}
1120
+ # itself only constrains the side that is present).
1121
+ #
1122
+ # Not available as a `field.not_between => value` symbol constraint,
1123
+ # unlike `.between`: a between-style range is inherently an OR of two
1124
+ # comparisons, and a single {Parse::Constraint}'s `#build` can only
1125
+ # safely contribute an AND'd clause to a query. A constraint that
1126
+ # unilaterally emitted a top-level `$or` would collide with (and
1127
+ # silently clobber, or be clobbered by) any other `$or` this query
1128
+ # already produces via {#or_where} / `|` / another `where_not_between`
1129
+ # call, since only one `$or` group merges correctly per query. This
1130
+ # method instead composes the negation the same way {Parse::Query.and}
1131
+ # does — concatenating compiled constraint arrays — which correctly
1132
+ # nests the OR inside the query's existing AND'd conditions instead of
1133
+ # replacing them the way {#or_where} would.
1134
+ #
1135
+ # @example
1136
+ # Person.query.where_not_between(:age, 5..25)
1137
+ # # age < 5 OR age > 25
1138
+ #
1139
+ # Record.query.where(:archived => false).where_not_between(:date, 5.days.ago...2.days.ago)
1140
+ # # archived == false AND (date < 5.days.ago OR date >= 2.days.ago)
1141
+ #
1142
+ # @param field [Symbol, String] the field to constrain.
1143
+ # @param value [Range, Array] a `between`-style value: a Range (including
1144
+ # beginless/endless/exclusive-end forms) or a 2-element `[min, max]` Array.
1145
+ # @return [self]
1146
+ # @raise [ArgumentError] if `value` isn't a Range or 2-element Array, or
1147
+ # is a fully-open (`nil..nil`) Range.
1148
+ def where_not_between(field, value)
1149
+ field = field.to_sym
1150
+ min_value, max_value, exclude_max = Parse::Constraint::BetweenConstraint.extract_bounds(value)
1151
+
1152
+ if min_value.nil? && max_value.nil?
1153
+ raise ArgumentError, "Query#where_not_between: Range must have a begin, an end, or both (ex. 5.., ..25, 5..25)."
1154
+ end
1155
+
1156
+ # A fully-bounded range needs its own `$or` group (`field < min OR
1157
+ # field > max`). Only ONE `$or` group survives the plain-Hash merge
1158
+ # every constraint's compiled output goes through (`constraint_reduce`
1159
+ # deep-merges compiled hashes; a second top-level `$or` key silently
1160
+ # overwrites the first rather than combining with it — the same
1161
+ # constraint that makes `field.not_between => value` unsafe as a
1162
+ # symbol constraint, see above). Fail loudly here instead of quietly
1163
+ # dropping half the query if this query already has one, from
1164
+ # `#or_where`, `|`, or an earlier `where_not_between` call.
1165
+ if min_value && max_value && @where.any? { |c| c.is_a?(Parse::Constraint::CompoundQueryConstraint) }
1166
+ raise ArgumentError,
1167
+ "Query#where_not_between: this query already has an `$or` group (from `or_where`, `|`, " \
1168
+ "or a prior `where_not_between` call). Only one `$or` group can be safely merged per " \
1169
+ "query. Compose the two queries with Parse::Query.and(...) instead."
1170
+ end
1171
+
1172
+ negated = if min_value.nil?
1173
+ Parse::Query.new(@table).where(field.public_send(exclude_max ? :gte : :gt) => max_value)
1174
+ elsif max_value.nil?
1175
+ Parse::Query.new(@table).where(field.lt => min_value)
1176
+ else
1177
+ lower = Parse::Query.new(@table).where(field.lt => min_value)
1178
+ upper = Parse::Query.new(@table).where(field.public_send(exclude_max ? :gte : :gt) => max_value)
1179
+ Parse::Query.or(lower, upper)
1180
+ end
1181
+
1182
+ @where = @where + negated.where
1183
+ @results = nil
1184
+ self
1185
+ end
1186
+
1116
1187
  # Queries can be made using distinct, allowing you find unique values for a specified field.
1117
1188
  # For this to be performant, please remember to index your database.
1118
1189
  # @example
@@ -1409,7 +1480,6 @@ module Parse
1409
1480
  return first_direct(limit_or_constraints)
1410
1481
  end
1411
1482
 
1412
- fetch_count = 1
1413
1483
  if limit_or_constraints.is_a?(Hash)
1414
1484
  conditions(limit_or_constraints)
1415
1485
  # Check if limit was set in constraints, otherwise use 1
@@ -1490,15 +1560,19 @@ module Parse
1490
1560
  # @return [Parse::Object] the object with the given ID.
1491
1561
  # @raise [Parse::Error] if the object is not found.
1492
1562
  def get(object_id)
1493
- parse_class = Object.const_get(@table) if Object.const_defined?(@table)
1494
- parse_class ||= Parse::Object
1495
-
1496
1563
  response = client.fetch_object(@table, object_id)
1497
1564
  if response.error?
1498
1565
  raise Parse::Error.new(response.code, response.error)
1499
1566
  end
1500
1567
 
1501
- Parse::Object.build(response.result, parse_class)
1568
+ # Pass the table name through as-is rather than pre-resolving it to
1569
+ # a Class: `Object.build` does its own `Parse::Model.find_class`
1570
+ # lookup against the String, which correctly honors `parse_class`
1571
+ # aliasing. Resolving to a Class first and handing that back to
1572
+ # `build` broke aliased lookups, since `find_class` would then
1573
+ # stringify the Ruby constant name (e.g. "Musician") instead of
1574
+ # matching the declared alias (e.g. "Artist").
1575
+ Parse::Object.build(response.result, @table)
1502
1576
  end
1503
1577
 
1504
1578
  # max_results is used to iterate through as many API requests as possible using
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.7.1"
9
+ VERSION = "5.7.2"
10
10
  end
11
11
  end
@@ -444,7 +444,6 @@ module Parse
444
444
  payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
445
445
  trusted_ruby_initiated = ruby_initiated && (payload.master? == true)
446
446
  else
447
- ruby_initiated = false
448
447
  trusted_ruby_initiated = false
449
448
  end
450
449
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.1
4
+ version: 5.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin