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,434 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
# A cursor-based pagination iterator for efficiently traversing large datasets.
|
|
6
|
+
#
|
|
7
|
+
# Unlike skip/offset pagination which becomes increasingly slow for large datasets,
|
|
8
|
+
# cursor-based pagination uses the last seen objectId to efficiently fetch the next page.
|
|
9
|
+
# This approach maintains consistent performance regardless of how deep into the dataset
|
|
10
|
+
# you paginate.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage with each_page
|
|
13
|
+
# cursor = Song.cursor(limit: 100, order: :created_at.desc)
|
|
14
|
+
# cursor.each_page do |page|
|
|
15
|
+
# process(page)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Using each to iterate over individual items
|
|
19
|
+
# Song.cursor(limit: 50).each do |song|
|
|
20
|
+
# puts song.title
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With constraints
|
|
24
|
+
# cursor = Song.cursor(artist: "Artist Name", limit: 25)
|
|
25
|
+
# cursor.each_page { |page| process(page) }
|
|
26
|
+
#
|
|
27
|
+
# @example Manual pagination control
|
|
28
|
+
# cursor = User.cursor(limit: 100)
|
|
29
|
+
# first_page = cursor.next_page
|
|
30
|
+
# second_page = cursor.next_page
|
|
31
|
+
# cursor.reset! # Start over from the beginning
|
|
32
|
+
#
|
|
33
|
+
class Cursor
|
|
34
|
+
include Enumerable
|
|
35
|
+
|
|
36
|
+
# Maximum page size allowed (Parse Server limit)
|
|
37
|
+
MAX_PAGE_SIZE = 1000
|
|
38
|
+
|
|
39
|
+
# Default page size
|
|
40
|
+
DEFAULT_PAGE_SIZE = 100
|
|
41
|
+
|
|
42
|
+
# @return [Parse::Query] the base query for this cursor
|
|
43
|
+
attr_reader :query
|
|
44
|
+
|
|
45
|
+
# @return [Integer] the number of items per page
|
|
46
|
+
attr_reader :page_size
|
|
47
|
+
|
|
48
|
+
# @return [String, nil] the current cursor position (objectId of last item)
|
|
49
|
+
attr_reader :position
|
|
50
|
+
|
|
51
|
+
# @return [Integer] the number of pages fetched so far
|
|
52
|
+
attr_reader :pages_fetched
|
|
53
|
+
|
|
54
|
+
# @return [Integer] the total number of items fetched so far
|
|
55
|
+
attr_reader :items_fetched
|
|
56
|
+
|
|
57
|
+
# @return [Symbol] the field to order by for cursor positioning
|
|
58
|
+
attr_reader :order_field
|
|
59
|
+
|
|
60
|
+
# @return [Symbol] the order direction (:asc or :desc)
|
|
61
|
+
attr_reader :order_direction
|
|
62
|
+
|
|
63
|
+
# Create a new cursor-based paginator.
|
|
64
|
+
#
|
|
65
|
+
# @param query [Parse::Query] the base query to paginate
|
|
66
|
+
# @param limit [Integer] the number of items per page (default: 100, max: 1000)
|
|
67
|
+
# @param order [Parse::Order, Symbol] the ordering for pagination.
|
|
68
|
+
# Defaults to :created_at.asc for stable ordering.
|
|
69
|
+
# Note: cursor pagination requires a stable sort order.
|
|
70
|
+
# @raise [ArgumentError] if limit exceeds MAX_PAGE_SIZE
|
|
71
|
+
def initialize(query, limit: DEFAULT_PAGE_SIZE, order: nil)
|
|
72
|
+
@query = query.dup
|
|
73
|
+
@page_size = validate_page_size(limit)
|
|
74
|
+
@position = nil
|
|
75
|
+
@pages_fetched = 0
|
|
76
|
+
@items_fetched = 0
|
|
77
|
+
@exhausted = false
|
|
78
|
+
|
|
79
|
+
# Set up ordering - cursor pagination needs a stable order
|
|
80
|
+
setup_ordering(order)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validate and normalize the page size.
|
|
84
|
+
# @param limit [Integer] the requested page size
|
|
85
|
+
# @return [Integer] the validated page size
|
|
86
|
+
# @raise [ArgumentError] if limit exceeds MAX_PAGE_SIZE
|
|
87
|
+
def validate_page_size(limit)
|
|
88
|
+
size = [limit.to_i, 1].max
|
|
89
|
+
|
|
90
|
+
if size > MAX_PAGE_SIZE
|
|
91
|
+
raise ArgumentError, "Page size #{size} exceeds maximum allowed (#{MAX_PAGE_SIZE}). " \
|
|
92
|
+
"Parse Server limits queries to #{MAX_PAGE_SIZE} results."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
size
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private :validate_page_size
|
|
99
|
+
|
|
100
|
+
# Check if more pages are available.
|
|
101
|
+
# @return [Boolean] true if more pages may be available
|
|
102
|
+
def more_pages?
|
|
103
|
+
!@exhausted
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if the cursor has been exhausted (no more results).
|
|
107
|
+
# @return [Boolean] true if all results have been fetched
|
|
108
|
+
def exhausted?
|
|
109
|
+
@exhausted
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fetch the next page of results.
|
|
113
|
+
# @return [Array<Parse::Object>] the next page of results
|
|
114
|
+
# @return [Array] empty array if no more results
|
|
115
|
+
def next_page
|
|
116
|
+
return [] if @exhausted
|
|
117
|
+
|
|
118
|
+
# Build the page query
|
|
119
|
+
page_query = build_page_query
|
|
120
|
+
|
|
121
|
+
# Execute the query
|
|
122
|
+
results = page_query.results
|
|
123
|
+
|
|
124
|
+
# Update state
|
|
125
|
+
if results.empty? || results.size < @page_size
|
|
126
|
+
@exhausted = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless results.empty?
|
|
130
|
+
@pages_fetched += 1
|
|
131
|
+
@items_fetched += results.size
|
|
132
|
+
@position = extract_cursor_position(results.last)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
results
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Reset the cursor to the beginning.
|
|
139
|
+
# @return [self]
|
|
140
|
+
def reset!
|
|
141
|
+
@position = nil
|
|
142
|
+
@pages_fetched = 0
|
|
143
|
+
@items_fetched = 0
|
|
144
|
+
@exhausted = false
|
|
145
|
+
self
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Iterate over each page of results.
|
|
149
|
+
# @yield [Array<Parse::Object>] each page of results
|
|
150
|
+
# @return [self]
|
|
151
|
+
def each_page
|
|
152
|
+
return enum_for(:each_page) unless block_given?
|
|
153
|
+
|
|
154
|
+
while more_pages?
|
|
155
|
+
page = next_page
|
|
156
|
+
break if page.empty?
|
|
157
|
+
yield page
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
self
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Iterate over each individual item.
|
|
164
|
+
# This is provided for Enumerable compatibility.
|
|
165
|
+
# @yield [Parse::Object] each item in the result set
|
|
166
|
+
# @return [self]
|
|
167
|
+
def each(&block)
|
|
168
|
+
return enum_for(:each) unless block_given?
|
|
169
|
+
|
|
170
|
+
each_page do |page|
|
|
171
|
+
page.each(&block)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Fetch all results at once.
|
|
178
|
+
# Use with caution on large datasets.
|
|
179
|
+
# @return [Array<Parse::Object>] all matching objects
|
|
180
|
+
def all
|
|
181
|
+
results = []
|
|
182
|
+
each_page { |page| results.concat(page) }
|
|
183
|
+
results
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get current cursor statistics.
|
|
187
|
+
# @return [Hash] statistics about the cursor pagination
|
|
188
|
+
def stats
|
|
189
|
+
{
|
|
190
|
+
pages_fetched: @pages_fetched,
|
|
191
|
+
items_fetched: @items_fetched,
|
|
192
|
+
page_size: @page_size,
|
|
193
|
+
exhausted: @exhausted,
|
|
194
|
+
position: @position,
|
|
195
|
+
order_field: @order_field,
|
|
196
|
+
order_direction: @order_direction,
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Serialize the cursor state to a JSON string for persistence.
|
|
201
|
+
# Useful for background jobs that may be interrupted and resumed.
|
|
202
|
+
#
|
|
203
|
+
# @example Save cursor state for later
|
|
204
|
+
# cursor = Song.cursor(limit: 100)
|
|
205
|
+
# cursor.next_page
|
|
206
|
+
# state = cursor.serialize
|
|
207
|
+
# # Store state in Redis, database, etc.
|
|
208
|
+
#
|
|
209
|
+
# @example Resume in a background job
|
|
210
|
+
# state = redis.get("cursor:#{job_id}")
|
|
211
|
+
# cursor = Parse::Cursor.deserialize(state)
|
|
212
|
+
# cursor.each_page { |page| process(page) }
|
|
213
|
+
#
|
|
214
|
+
# @return [String] JSON string containing cursor state
|
|
215
|
+
def serialize
|
|
216
|
+
require "json"
|
|
217
|
+
state = {
|
|
218
|
+
class_name: @query.table,
|
|
219
|
+
constraints: @query.constraints(true),
|
|
220
|
+
page_size: @page_size,
|
|
221
|
+
position: @position,
|
|
222
|
+
last_order_value: serialize_value(@last_order_value),
|
|
223
|
+
last_object_id: @last_object_id,
|
|
224
|
+
pages_fetched: @pages_fetched,
|
|
225
|
+
items_fetched: @items_fetched,
|
|
226
|
+
exhausted: @exhausted,
|
|
227
|
+
order_field: @order_field,
|
|
228
|
+
order_direction: @order_direction,
|
|
229
|
+
version: 1, # For future compatibility
|
|
230
|
+
}
|
|
231
|
+
JSON.generate(state)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Alias for serialize
|
|
235
|
+
# @return [String] JSON string containing cursor state
|
|
236
|
+
def to_json
|
|
237
|
+
serialize
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Deserialize a cursor from a previously serialized state.
|
|
241
|
+
#
|
|
242
|
+
# @param json_string [String] the serialized cursor state
|
|
243
|
+
# @return [Parse::Cursor] a cursor restored to the saved state
|
|
244
|
+
# @raise [ArgumentError] if the JSON is invalid or missing required fields
|
|
245
|
+
#
|
|
246
|
+
# @example Resume a cursor
|
|
247
|
+
# cursor = Parse::Cursor.deserialize(saved_state)
|
|
248
|
+
# cursor.each_page { |page| process(page) }
|
|
249
|
+
def self.deserialize(json_string)
|
|
250
|
+
require "json"
|
|
251
|
+
state = JSON.parse(json_string, symbolize_names: true)
|
|
252
|
+
|
|
253
|
+
# Validate required fields
|
|
254
|
+
required = [:class_name, :page_size, :order_field, :order_direction]
|
|
255
|
+
missing = required.select { |f| state[f].nil? }
|
|
256
|
+
unless missing.empty?
|
|
257
|
+
raise ArgumentError, "Invalid cursor state: missing #{missing.join(", ")}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Get the model class
|
|
261
|
+
klass = Parse::Model.find_class(state[:class_name])
|
|
262
|
+
unless klass
|
|
263
|
+
raise ArgumentError, "Unknown Parse class: #{state[:class_name]}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Rebuild the query
|
|
267
|
+
query = klass.query(state[:constraints] || {})
|
|
268
|
+
|
|
269
|
+
# Create the cursor with the original order
|
|
270
|
+
order = state[:order_direction].to_sym == :desc ?
|
|
271
|
+
state[:order_field].to_s.to_sym.desc :
|
|
272
|
+
state[:order_field].to_s.to_sym.asc
|
|
273
|
+
|
|
274
|
+
cursor = new(query, limit: state[:page_size], order: order)
|
|
275
|
+
|
|
276
|
+
# Restore state
|
|
277
|
+
cursor.instance_variable_set(:@position, state[:position])
|
|
278
|
+
cursor.instance_variable_set(:@last_order_value, deserialize_value(state[:last_order_value]))
|
|
279
|
+
cursor.instance_variable_set(:@last_object_id, state[:last_object_id])
|
|
280
|
+
cursor.instance_variable_set(:@pages_fetched, state[:pages_fetched] || 0)
|
|
281
|
+
cursor.instance_variable_set(:@items_fetched, state[:items_fetched] || 0)
|
|
282
|
+
cursor.instance_variable_set(:@exhausted, state[:exhausted] || false)
|
|
283
|
+
|
|
284
|
+
cursor
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Alias for deserialize
|
|
288
|
+
# @param json_string [String] the serialized cursor state
|
|
289
|
+
# @return [Parse::Cursor] a cursor restored to the saved state
|
|
290
|
+
def self.from_json(json_string)
|
|
291
|
+
deserialize(json_string)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
# Serialize a value for JSON storage (handles dates, etc.)
|
|
297
|
+
def serialize_value(value)
|
|
298
|
+
case value
|
|
299
|
+
when DateTime, Time
|
|
300
|
+
{ "__type" => "Date", "iso" => value.utc.iso8601(3) }
|
|
301
|
+
when Date
|
|
302
|
+
{ "__type" => "Date", "iso" => value.to_datetime.utc.iso8601(3) }
|
|
303
|
+
else
|
|
304
|
+
value
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Deserialize a value from JSON storage
|
|
309
|
+
def self.deserialize_value(value)
|
|
310
|
+
return value unless value.is_a?(Hash) && value["__type"] == "Date"
|
|
311
|
+
DateTime.parse(value["iso"])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Set up the ordering for cursor pagination.
|
|
315
|
+
# Cursor pagination requires a stable sort order.
|
|
316
|
+
def setup_ordering(order)
|
|
317
|
+
if order.nil?
|
|
318
|
+
# Default to created_at ascending for stable pagination
|
|
319
|
+
@order_field = :createdAt
|
|
320
|
+
@order_direction = :asc
|
|
321
|
+
@query.order(:created_at.asc)
|
|
322
|
+
elsif order.is_a?(Parse::Order)
|
|
323
|
+
@order_field = order.field.to_sym
|
|
324
|
+
@order_direction = order.direction
|
|
325
|
+
@query.clear(:order)
|
|
326
|
+
@query.order(order)
|
|
327
|
+
elsif order.respond_to?(:to_sym)
|
|
328
|
+
# Handle plain symbol like :created_at (without .desc/.asc)
|
|
329
|
+
order_obj = Parse::Order.new(order)
|
|
330
|
+
@order_field = order_obj.field.to_sym
|
|
331
|
+
@order_direction = order_obj.direction
|
|
332
|
+
@query.clear(:order)
|
|
333
|
+
@query.order(order)
|
|
334
|
+
else
|
|
335
|
+
@order_field = :createdAt
|
|
336
|
+
@order_direction = :asc
|
|
337
|
+
@query.order(:created_at.asc)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Always add objectId as secondary sort for stability
|
|
341
|
+
# This ensures consistent ordering when primary sort values are equal
|
|
342
|
+
unless @order_field == :objectId
|
|
343
|
+
secondary_order = @order_direction == :desc ? :objectId.desc : :objectId.asc
|
|
344
|
+
@query.order(secondary_order)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Build the query for the next page.
|
|
349
|
+
def build_page_query
|
|
350
|
+
page_query = @query.dup
|
|
351
|
+
page_query.limit(@page_size)
|
|
352
|
+
|
|
353
|
+
if @position && @last_order_value && @last_object_id
|
|
354
|
+
# Use composite cursor constraint to handle ties correctly:
|
|
355
|
+
# (field < last_value) OR (field = last_value AND objectId < last_id)
|
|
356
|
+
# This ensures no records are skipped when multiple records have the same order field value.
|
|
357
|
+
or_constraint = build_cursor_constraint
|
|
358
|
+
page_query.add_constraints([or_constraint])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
page_query
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Build the OR constraint for cursor positioning.
|
|
365
|
+
# Returns: (field < last_value) OR (field = last_value AND objectId < last_id)
|
|
366
|
+
# for descending order, or the inverse for ascending.
|
|
367
|
+
def build_cursor_constraint
|
|
368
|
+
formatted_field = Parse::Query.format_field(@order_field)
|
|
369
|
+
|
|
370
|
+
if @order_direction == :desc
|
|
371
|
+
# Descending: (field < last_value) OR (field = last_value AND objectId < last_id)
|
|
372
|
+
clause1 = { formatted_field => { "$lt" => format_cursor_value(@last_order_value) } }
|
|
373
|
+
clause2 = {
|
|
374
|
+
formatted_field => format_cursor_value(@last_order_value),
|
|
375
|
+
"objectId" => { "$lt" => @last_object_id },
|
|
376
|
+
}
|
|
377
|
+
else
|
|
378
|
+
# Ascending: (field > last_value) OR (field = last_value AND objectId > last_id)
|
|
379
|
+
clause1 = { formatted_field => { "$gt" => format_cursor_value(@last_order_value) } }
|
|
380
|
+
clause2 = {
|
|
381
|
+
formatted_field => format_cursor_value(@last_order_value),
|
|
382
|
+
"objectId" => { "$gt" => @last_object_id },
|
|
383
|
+
}
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
Parse::Constraint::CompoundQueryConstraint.new(:or, [clause1, clause2])
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Format cursor value for use in constraint.
|
|
390
|
+
# Handles Date/Time objects that need ISO8601 formatting for Parse.
|
|
391
|
+
def format_cursor_value(value)
|
|
392
|
+
case value
|
|
393
|
+
when DateTime, Time
|
|
394
|
+
{ "__type" => "Date", "iso" => value.utc.iso8601(3) }
|
|
395
|
+
when Date
|
|
396
|
+
{ "__type" => "Date", "iso" => value.to_datetime.utc.iso8601(3) }
|
|
397
|
+
else
|
|
398
|
+
value
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Extract cursor position from the last item in a page.
|
|
403
|
+
def extract_cursor_position(item)
|
|
404
|
+
return nil unless item
|
|
405
|
+
|
|
406
|
+
# Store both the order field value and objectId for precise cursor positioning
|
|
407
|
+
@last_order_value = get_field_value(item, @order_field)
|
|
408
|
+
@last_object_id = item.id
|
|
409
|
+
|
|
410
|
+
item.id
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Get the value of a field from an item.
|
|
414
|
+
def get_field_value(item, field)
|
|
415
|
+
case field
|
|
416
|
+
when :createdAt, :created_at
|
|
417
|
+
item.created_at
|
|
418
|
+
when :updatedAt, :updated_at
|
|
419
|
+
item.updated_at
|
|
420
|
+
when :objectId, :id
|
|
421
|
+
item.id
|
|
422
|
+
else
|
|
423
|
+
# Try the field as a method
|
|
424
|
+
if item.respond_to?(field)
|
|
425
|
+
item.send(field)
|
|
426
|
+
elsif item.respond_to?(:attributes) && item.attributes[field.to_s]
|
|
427
|
+
item.attributes[field.to_s]
|
|
428
|
+
else
|
|
429
|
+
nil
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|