parse-stack-next 5.1.1 → 5.2.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 +4 -4
- data/.env.sample +12 -0
- data/.env.test +4 -4
- data/CHANGELOG.md +545 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +167 -38
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +433 -0
- data/docs/mongodb_direct_guide.md +66 -1
- data/docs/mongodb_index_optimization_guide.md +22 -1
- data/docs/usage_guide.md +15 -0
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +90 -19
- data/lib/parse/agent/describe.rb +1 -0
- data/lib/parse/agent/errors.rb +16 -0
- data/lib/parse/agent/mcp_client.rb +9 -0
- data/lib/parse/agent/mcp_dispatcher.rb +139 -7
- data/lib/parse/agent/mcp_rack_app.rb +621 -17
- data/lib/parse/agent/mcp_subscriptions.rb +607 -0
- data/lib/parse/agent/metadata_dsl.rb +58 -0
- data/lib/parse/agent/metadata_registry.rb +141 -1
- data/lib/parse/agent/prompt_hardening.rb +213 -0
- data/lib/parse/agent/result_formatter.rb +18 -3
- data/lib/parse/agent/tools.rb +167 -24
- data/lib/parse/agent.rb +692 -21
- data/lib/parse/client/request.rb +55 -4
- data/lib/parse/client/response.rb +4 -0
- data/lib/parse/client.rb +205 -7
- data/lib/parse/model/classes/installation.rb +27 -10
- data/lib/parse/model/classes/user.rb +8 -0
- data/lib/parse/model/core/actions.rb +58 -4
- data/lib/parse/model/core/embed_managed.rb +19 -14
- data/lib/parse/model/core/indexing.rb +108 -16
- data/lib/parse/model/core/querying.rb +29 -0
- data/lib/parse/model/model.rb +34 -3
- data/lib/parse/model/object.rb +1 -0
- data/lib/parse/query.rb +90 -24
- data/lib/parse/retrieval/agent_tool.rb +369 -0
- data/lib/parse/retrieval/chunk.rb +74 -0
- data/lib/parse/retrieval/chunker.rb +208 -0
- data/lib/parse/retrieval/retriever.rb +274 -0
- data/lib/parse/retrieval.rb +10 -0
- data/lib/parse/schema.rb +69 -20
- data/lib/parse/stack/version.rb +2 -2
- data/parse-stack-next.gemspec +1 -1
- data/scripts/docker/docker-compose.atlas.yml +14 -10
- data/scripts/docker/docker-compose.test.yml +24 -20
- data/scripts/docker/mongo-init.js +3 -3
- data/scripts/start-parse.sh +10 -0
- data/scripts/start_mcp_server.rb +1 -1
- data/scripts/test_server_connection.rb +1 -1
- data/scripts/vector_prototype/create_vector_index.js +1 -1
- data/scripts/vector_prototype/fetch_embeddings.py +2 -2
- data/scripts/vector_prototype/query_prototype.rb +1 -1
- data/scripts/vector_prototype/run.sh +4 -4
- metadata +10 -2
|
@@ -56,6 +56,15 @@ module Parse
|
|
|
56
56
|
_perishable_token _password_history authData _auth_data
|
|
57
57
|
].freeze
|
|
58
58
|
|
|
59
|
+
# Guards the check-then-append in the index registration helpers. Index
|
|
60
|
+
# declarations happen at class-load time, but an app server that eager-
|
|
61
|
+
# loads models across multiple threads can otherwise have two threads
|
|
62
|
+
# both pass the idempotency check on the same (still-empty) array and
|
|
63
|
+
# append duplicate declarations — producing duplicate `createIndex`
|
|
64
|
+
# calls at migration time. A single shared mutex is sufficient: this is
|
|
65
|
+
# not a hot path, so coarse locking trades nothing for correctness.
|
|
66
|
+
INDEX_REGISTRY_MUTEX = Mutex.new
|
|
67
|
+
|
|
59
68
|
# Storage for declared indexes. Each entry is a frozen Hash with
|
|
60
69
|
# the keys `:keys`, `:options`, `:declared_for` (the source-of-truth
|
|
61
70
|
# symbol list from the `mongo_index` call, for diagnostics).
|
|
@@ -89,6 +98,78 @@ module Parse
|
|
|
89
98
|
partial: partial, expire_after: expire_after, name: name)
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
# Declare a UNIQUE index on the exact dedup tuple that
|
|
102
|
+
# `first_or_create!` / `create_or_update!` key on. This is the
|
|
103
|
+
# *correctness floor* for the synchronize-create race.
|
|
104
|
+
#
|
|
105
|
+
# The Redis-backed `synchronize:` lock (see {#first_or_create!}) is a
|
|
106
|
+
# latency optimization: in the common path it collapses concurrent
|
|
107
|
+
# callers so only one issues the create. But a lock can be bypassed —
|
|
108
|
+
# a Redis outage, a TTL expiring between the existence check and the
|
|
109
|
+
# write, a caller passing `synchronize: false`, or two app servers
|
|
110
|
+
# whose lock secrets disagree. When that happens, the *database* is the
|
|
111
|
+
# last line of defense. A unique index guarantees, unconditionally, that
|
|
112
|
+
# two racing inserts can't both land: the loser fails with DuplicateValue
|
|
113
|
+
# (Parse error 137), which `first_or_create!` rescues and resolves to the
|
|
114
|
+
# winning row via `_recover_from_duplicate_value`. Lock + index together
|
|
115
|
+
# make the net invariant "exactly one row, every caller sees the same id"
|
|
116
|
+
# hold under any race, not just the happy path.
|
|
117
|
+
#
|
|
118
|
+
# This is thin sugar over `mongo_index(*fields, unique: true, ...)` —
|
|
119
|
+
# it shares the same registration, validation (sensitive-field guard,
|
|
120
|
+
# pointer auto-rewrite, parallel-array / relation / `_id` rejection),
|
|
121
|
+
# and `IndexMigrator` apply path. The name states the intent: these
|
|
122
|
+
# fields form the dedup identity for create-or-update.
|
|
123
|
+
#
|
|
124
|
+
# Defaults match `mongo_index`: **non-sparse**. The index key is kept
|
|
125
|
+
# identical to the query `first_or_create!` re-runs on recovery, so a
|
|
126
|
+
# 137 always corresponds to a row the recovery query (`_scoped_first`
|
|
127
|
+
# on the same `query_attrs`) can find. A sparse or partial index that
|
|
128
|
+
# fires on a condition the recovery query doesn't reproduce would
|
|
129
|
+
# surface a 137 the rescue can't resolve, and the error would re-raise.
|
|
130
|
+
# `sparse:` is meaningful only when a document is missing *every* field
|
|
131
|
+
# in the tuple (a compound sparse index indexes a doc when it has at
|
|
132
|
+
# least one key); since `first_or_create!` always writes the full tuple,
|
|
133
|
+
# it never produces such a row, so sparse does not weaken the floor —
|
|
134
|
+
# leave it off unless out-of-band writers create tuple-less rows you
|
|
135
|
+
# want excluded.
|
|
136
|
+
#
|
|
137
|
+
# @example Single-field dedup floor
|
|
138
|
+
# class Account < Parse::Object
|
|
139
|
+
# property :email, :string
|
|
140
|
+
# unique_index_on :email
|
|
141
|
+
# end
|
|
142
|
+
# Account.apply_indexes! # provisions { email: 1 } unique via the writer
|
|
143
|
+
#
|
|
144
|
+
# @example Compound tuple with a pointer component
|
|
145
|
+
# class Subscription < Parse::Object
|
|
146
|
+
# property :email, :string
|
|
147
|
+
# belongs_to :tenant, as: :user
|
|
148
|
+
# unique_index_on :email, :tenant # key: { email: 1, _p_tenant: 1 } unique
|
|
149
|
+
# end
|
|
150
|
+
#
|
|
151
|
+
# @example Unique within a subset (partial filter escape hatch)
|
|
152
|
+
# # Unique email per tenant, but rows with no tenant may repeat. You
|
|
153
|
+
# # own the filter's lifecycle and must keep first_or_create!'s
|
|
154
|
+
# # recovery query consistent with it.
|
|
155
|
+
# unique_index_on :email, :tenant,
|
|
156
|
+
# partial: { "_p_tenant" => { "$exists" => true } }
|
|
157
|
+
#
|
|
158
|
+
# @param fields [Array<Symbol>] the dedup tuple, in declaration order.
|
|
159
|
+
# Pointer fields auto-rewrite to `_p_<field>` like `mongo_index`.
|
|
160
|
+
# @param sparse [Boolean] default `false`; see the note above on why
|
|
161
|
+
# it does not weaken the floor and when it actually changes behavior.
|
|
162
|
+
# @param partial [Hash, nil] partial-index filter for "unique within a
|
|
163
|
+
# subset". Owner-managed; keep it consistent with the recovery query.
|
|
164
|
+
# @param name [String, nil] explicit index name; defaults to MongoDB
|
|
165
|
+
# auto-naming.
|
|
166
|
+
# @return [Hash] the registered declaration (frozen)
|
|
167
|
+
# @raise [ArgumentError] same guards as `mongo_index`.
|
|
168
|
+
# @see #first_or_create!
|
|
169
|
+
def unique_index_on(*fields, sparse: false, partial: nil, name: nil)
|
|
170
|
+
mongo_index(*fields, unique: true, sparse: sparse, partial: partial, name: name)
|
|
171
|
+
end
|
|
172
|
+
|
|
92
173
|
# Sugar for a 2dsphere geospatial index. Geopoint columns are
|
|
93
174
|
# stored in Mongo as GeoJSON `{ type: "Point", coordinates: [lng, lat] }`
|
|
94
175
|
# which `2dsphere` indexes natively.
|
|
@@ -232,13 +313,10 @@ module Parse
|
|
|
232
313
|
collection: nil, # nil sentinel means "use the model's parse_class"
|
|
233
314
|
}.freeze
|
|
234
315
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
end
|
|
240
|
-
mongo_index_declarations << declaration
|
|
241
|
-
declaration
|
|
316
|
+
# Idempotent redeclaration (same class re-opened or sub-class
|
|
317
|
+
# inherited) is dropped inside the locked append so a duplicate can't
|
|
318
|
+
# slip through under concurrent class loading.
|
|
319
|
+
append_index_declaration(declaration)
|
|
242
320
|
end
|
|
243
321
|
|
|
244
322
|
# Register one direction of a relation index. The declaration
|
|
@@ -252,11 +330,7 @@ module Parse
|
|
|
252
330
|
declared_for: [source].freeze,
|
|
253
331
|
collection: collection,
|
|
254
332
|
}.freeze
|
|
255
|
-
|
|
256
|
-
return decl
|
|
257
|
-
end
|
|
258
|
-
mongo_index_declarations << decl
|
|
259
|
-
decl
|
|
333
|
+
append_index_declaration(decl)
|
|
260
334
|
end
|
|
261
335
|
|
|
262
336
|
# Register the compound `{owningId: 1, relatedId: 1}` unique index
|
|
@@ -274,11 +348,29 @@ module Parse
|
|
|
274
348
|
declared_for: [source].freeze,
|
|
275
349
|
collection: collection,
|
|
276
350
|
}.freeze
|
|
277
|
-
|
|
278
|
-
|
|
351
|
+
append_index_declaration(decl)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Append an index declaration, dropping an exact-duplicate redeclaration,
|
|
355
|
+
# under {INDEX_REGISTRY_MUTEX} so concurrent class loading can't race a
|
|
356
|
+
# duplicate past the idempotency check. Two declarations are duplicates
|
|
357
|
+
# when their `:keys`, `:options`, and `:collection` all match (relation
|
|
358
|
+
# declarations carry a frozen `{}` options hash, so this is equivalent to
|
|
359
|
+
# the prior keys+collection check for those paths).
|
|
360
|
+
# @return [Hash] the declaration passed in (whether newly stored or a
|
|
361
|
+
# dropped duplicate), preserving the previous return contract.
|
|
362
|
+
def append_index_declaration(declaration)
|
|
363
|
+
Parse::Core::Indexing::INDEX_REGISTRY_MUTEX.synchronize do
|
|
364
|
+
decls = (@mongo_index_declarations ||= [])
|
|
365
|
+
unless decls.any? { |d|
|
|
366
|
+
d[:keys] == declaration[:keys] &&
|
|
367
|
+
d[:options] == declaration[:options] &&
|
|
368
|
+
d[:collection] == declaration[:collection]
|
|
369
|
+
}
|
|
370
|
+
decls << declaration
|
|
371
|
+
end
|
|
279
372
|
end
|
|
280
|
-
|
|
281
|
-
decl
|
|
373
|
+
declaration
|
|
282
374
|
end
|
|
283
375
|
|
|
284
376
|
# Translate a property symbol to the wire-format column name a
|
|
@@ -368,6 +368,35 @@ module Parse
|
|
|
368
368
|
query(constraints).distinct(field)
|
|
369
369
|
end
|
|
370
370
|
|
|
371
|
+
# Groups records by a field and returns a GroupBy (or SortableGroupBy)
|
|
372
|
+
# aggregation object you can call .count, .sum, .average, etc. on.
|
|
373
|
+
# @example
|
|
374
|
+
# Post.group_by(:category).count
|
|
375
|
+
# Post.group_by(:category, sortable: true).count.sort_by_value_desc
|
|
376
|
+
# @param field [Symbol, String] the field to group by.
|
|
377
|
+
# @param opts keyword arguments forwarded to Parse::Query#group_by
|
|
378
|
+
# (flatten_arrays:, sortable:, return_pointers:, mongo_direct:).
|
|
379
|
+
# @return [Parse::GroupBy, Parse::SortableGroupBy]
|
|
380
|
+
# @see Parse::Query#group_by
|
|
381
|
+
def group_by(field, **opts)
|
|
382
|
+
query.group_by(field, **opts)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Groups records by a date field truncated to the given interval and
|
|
386
|
+
# returns a GroupByDate (or SortableGroupByDate) aggregation object.
|
|
387
|
+
# @example
|
|
388
|
+
# Post.group_by_date(:created_at, :month).count
|
|
389
|
+
# Post.group_by_date(:created_at, :day, sortable: true).count.sort_by_value_desc
|
|
390
|
+
# @param field [Symbol, String] the date field to group by.
|
|
391
|
+
# @param interval [Symbol] one of :year, :month, :week, :day, :hour, :minute, :second.
|
|
392
|
+
# @param opts keyword arguments forwarded to Parse::Query#group_by_date
|
|
393
|
+
# (sortable:, return_pointers:, timezone:, mongo_direct:).
|
|
394
|
+
# @return [Parse::GroupByDate, Parse::SortableGroupByDate]
|
|
395
|
+
# @see Parse::Query#group_by_date
|
|
396
|
+
def group_by_date(field, interval, **opts)
|
|
397
|
+
query.group_by_date(field, interval, **opts)
|
|
398
|
+
end
|
|
399
|
+
|
|
371
400
|
# Find objects matching the constraint ordered by the descending created_at date.
|
|
372
401
|
# @param constraints (see #all)
|
|
373
402
|
# @return [Array<Parse::Object>]
|
data/lib/parse/model/model.rb
CHANGED
|
@@ -77,6 +77,33 @@ module Parse
|
|
|
77
77
|
CLASS_JOB_SCHEDULE = "_JobSchedule"
|
|
78
78
|
# The internal schema collection in Parse. Managed by Parse Server.
|
|
79
79
|
CLASS_SCHEMA = "_SCHEMA"
|
|
80
|
+
# Maps the camelized bare name of a Parse Server built-in class to its
|
|
81
|
+
# leading-underscore storage form. Consulted by {String#to_parse_class}
|
|
82
|
+
# ONLY as a fallback when {find_class} cannot resolve the name — which
|
|
83
|
+
# happens at class-declaration time for a built-in whose Ruby class is not
|
|
84
|
+
# yet registered (e.g. +Parse::Installation+ declares +belongs_to :user+
|
|
85
|
+
# before +Parse::User+ is loaded). Without this fallback the conversion
|
|
86
|
+
# froze the wrong literal (+"User"+) into the association +references+ map
|
|
87
|
+
# and pushed it to the server schema as the pointer +targetClass+, which
|
|
88
|
+
# Parse Server then rejected (+Pointer<User>+ vs +Pointer<_User>+). A
|
|
89
|
+
# genuinely-registered class still wins via {find_class}, so a custom
|
|
90
|
+
# +parse_class+ table mapping is never overridden by this map.
|
|
91
|
+
# Keys are the camelized bare names (the form {String#to_parse_class}
|
|
92
|
+
# computes before lookup); only +User+ is actually targeted by a built-in
|
|
93
|
+
# association before its class registers, the rest are hygiene so an app
|
|
94
|
+
# that declares a pointer/relation to any built-in resolves correctly
|
|
95
|
+
# regardless of load order.
|
|
96
|
+
SYSTEM_CLASS_MAP = {
|
|
97
|
+
"User" => CLASS_USER,
|
|
98
|
+
"Role" => CLASS_ROLE,
|
|
99
|
+
"Session" => CLASS_SESSION,
|
|
100
|
+
"Installation" => CLASS_INSTALLATION,
|
|
101
|
+
"Product" => CLASS_PRODUCT,
|
|
102
|
+
"Audience" => CLASS_AUDIENCE,
|
|
103
|
+
"PushStatus" => CLASS_PUSH_STATUS,
|
|
104
|
+
"JobStatus" => CLASS_JOB_STATUS,
|
|
105
|
+
"JobSchedule" => CLASS_JOB_SCHEDULE,
|
|
106
|
+
}.freeze
|
|
80
107
|
# The type label for hashes containing file data. Used by Parse::File.
|
|
81
108
|
TYPE_FILE = "File"
|
|
82
109
|
# The type label for hashes containing geopoints. Used by Parse::GeoPoint.
|
|
@@ -286,9 +313,13 @@ class String
|
|
|
286
313
|
def to_parse_class(singularize: false)
|
|
287
314
|
final_class = singularize ? self.singularize.camelize : self.camelize
|
|
288
315
|
klass = Parse::Model.find_class(final_class) || Parse::Model.find_class(self)
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
# A registered class wins (handles a custom parse_class table mapping).
|
|
317
|
+
return klass.parse_class if klass.present?
|
|
318
|
+
# Fallback: resolve a built-in system class by name even when its Ruby
|
|
319
|
+
# class is not yet registered (declaration-time load-order). This keeps a
|
|
320
|
+
# `belongs_to :user` declared before Parse::User loads from freezing the
|
|
321
|
+
# literal "User" instead of "_User" into the schema targetClass.
|
|
322
|
+
Parse::Model::SYSTEM_CLASS_MAP[final_class] || final_class
|
|
292
323
|
end
|
|
293
324
|
end
|
|
294
325
|
|
data/lib/parse/model/object.rb
CHANGED
|
@@ -38,6 +38,7 @@ require_relative "core/search_indexing"
|
|
|
38
38
|
require_relative "core/properties"
|
|
39
39
|
require_relative "core/vector_searchable"
|
|
40
40
|
require_relative "core/embed_managed"
|
|
41
|
+
require_relative "../retrieval"
|
|
41
42
|
require_relative "core/errors"
|
|
42
43
|
require_relative "core/builder"
|
|
43
44
|
require_relative "core/enhanced_change_tracking"
|
data/lib/parse/query.rb
CHANGED
|
@@ -69,19 +69,46 @@ module Parse
|
|
|
69
69
|
include Parse::Client::Connectable
|
|
70
70
|
include Enumerable
|
|
71
71
|
|
|
72
|
-
#
|
|
72
|
+
# Built-in Parse classes always considered known, independent of the
|
|
73
|
+
# server schema. Used both as the seed for the dynamic list and as the
|
|
74
|
+
# transient fallback when the schema fetch fails.
|
|
75
|
+
BUILT_IN_PARSE_CLASSES = %w[
|
|
76
|
+
_User _Role _Session _Installation _Audience
|
|
77
|
+
User Role Session Installation Audience
|
|
78
|
+
].freeze
|
|
79
|
+
|
|
80
|
+
# Mutex guarding lazy memoization of {known_parse_classes} so concurrent
|
|
81
|
+
# first-callers don't each fire a `schemas` request and clobber the cache.
|
|
82
|
+
@known_parse_classes_mutex = Mutex.new
|
|
83
|
+
|
|
84
|
+
# Known Parse classes for fast validation - dynamically loaded from schema.
|
|
85
|
+
#
|
|
86
|
+
# The successful result is memoized; a failed schema fetch is NOT cached —
|
|
87
|
+
# it returns the built-in fallback for this call only, so a transient
|
|
88
|
+
# server outage during boot doesn't permanently strip every application-
|
|
89
|
+
# defined class from the known set (which would make class-accessibility
|
|
90
|
+
# checks reject custom classes for the process lifetime). The narrowed
|
|
91
|
+
# rescue logs the failure instead of swallowing it silently.
|
|
73
92
|
def self.known_parse_classes
|
|
74
|
-
@known_parse_classes
|
|
75
|
-
|
|
93
|
+
cached = @known_parse_classes
|
|
94
|
+
return cached if cached
|
|
95
|
+
|
|
96
|
+
@known_parse_classes_mutex.synchronize do
|
|
97
|
+
# Re-check under the lock: a racing caller may have populated it.
|
|
98
|
+
return @known_parse_classes if @known_parse_classes
|
|
99
|
+
|
|
100
|
+
begin
|
|
76
101
|
response = Parse.client.schemas
|
|
77
|
-
schema_classes = response.success? ? response.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
schema_classes = response.success? ? response.results.map { |cls| cls["className"] } : []
|
|
103
|
+
@known_parse_classes = (BUILT_IN_PARSE_CLASSES + schema_classes).uniq.freeze
|
|
104
|
+
rescue Parse::Error, Faraday::Error => e
|
|
105
|
+
# Don't cache the fallback — let the next call retry the fetch once
|
|
106
|
+
# the server is reachable again.
|
|
107
|
+
warn "[Parse::Query] schema fetch failed (#{e.class}: #{e.message}); " \
|
|
108
|
+
"falling back to built-in classes for this check only."
|
|
109
|
+
BUILT_IN_PARSE_CLASSES
|
|
84
110
|
end
|
|
111
|
+
end
|
|
85
112
|
end
|
|
86
113
|
|
|
87
114
|
# Allow resetting the cached known classes (useful for testing)
|
|
@@ -2564,6 +2591,26 @@ module Parse
|
|
|
2564
2591
|
def convert_constraints_for_direct_mongodb(constraints)
|
|
2565
2592
|
return constraints unless constraints.is_a?(Hash)
|
|
2566
2593
|
|
|
2594
|
+
# $relatedTo resolves a Parse Relation, which is stored in the
|
|
2595
|
+
# `_Join:<key>:<ParentClass>` collection — a join the SDK does NOT
|
|
2596
|
+
# translate on the mongo-direct path. Passed through verbatim it reaches
|
|
2597
|
+
# MongoDB as an unknown `$match` operator and fails with an opaque error;
|
|
2598
|
+
# and any future attempt to rewrite it into a `$lookup` would have to
|
|
2599
|
+
# re-implement the `_rperm` / protectedFields enforcement that the rest of
|
|
2600
|
+
# this path applies post-fetch. Parse Server's own `$relatedTo` was found
|
|
2601
|
+
# to bypass exactly that enforcement (GHSA-wmwx-jr2p-4j4r), so fail closed
|
|
2602
|
+
# here with a clear message rather than risk a silent leak: this query
|
|
2603
|
+
# must run via REST (the default), where Parse Server resolves the
|
|
2604
|
+
# relation under its own ACL / CLP enforcement.
|
|
2605
|
+
if constraints.key?("$relatedTo") || constraints.key?(:"$relatedTo")
|
|
2606
|
+
raise ArgumentError,
|
|
2607
|
+
"[Parse::Query] $relatedTo cannot run on the mongo-direct path; a " \
|
|
2608
|
+
"Parse Relation is resolved server-side via its join collection. Run " \
|
|
2609
|
+
"this query via REST (omit `mongo_direct:` / `.results_direct` and any " \
|
|
2610
|
+
"direct-only constraint), or express the membership as an `$inQuery` " \
|
|
2611
|
+
"against the relation's join collection."
|
|
2612
|
+
end
|
|
2613
|
+
|
|
2567
2614
|
result = {}
|
|
2568
2615
|
constraints.each do |field, value|
|
|
2569
2616
|
field_str = field.to_s
|
|
@@ -6284,8 +6331,10 @@ module Parse
|
|
|
6284
6331
|
include Enumerable
|
|
6285
6332
|
|
|
6286
6333
|
# @param results [Hash] the grouped results hash
|
|
6287
|
-
|
|
6334
|
+
# @param operation [String, nil] the aggregation operation (e.g. "count", "sum", "average", "min", "max", "list")
|
|
6335
|
+
def initialize(results, operation = nil)
|
|
6288
6336
|
@results = results
|
|
6337
|
+
@operation = operation
|
|
6289
6338
|
end
|
|
6290
6339
|
|
|
6291
6340
|
# Return the raw hash results
|
|
@@ -6332,12 +6381,15 @@ module Parse
|
|
|
6332
6381
|
|
|
6333
6382
|
# Convert grouped results to a formatted table.
|
|
6334
6383
|
# @param format [Symbol] output format (:ascii, :csv, :json)
|
|
6335
|
-
# @param headers [Array<String
|
|
6384
|
+
# @param headers [Array<String>, nil] custom headers; if nil, defaults to ["Group", <op-derived header>]
|
|
6385
|
+
# where the second header reflects the aggregation operation (e.g. "Average" for avg/average,
|
|
6386
|
+
# "Sum" for sum, "Min"/"Max" for min/max, "Items" for list, "Count" otherwise).
|
|
6336
6387
|
# @return [String] formatted table
|
|
6337
6388
|
# @example
|
|
6338
6389
|
# Document.group_by(:category, sortable: true).count.to_table
|
|
6339
6390
|
# Document.group_by(:category).sum(:file_size).to_table(headers: ["Category", "Total Size"])
|
|
6340
|
-
def to_table(format: :ascii, headers:
|
|
6391
|
+
def to_table(format: :ascii, headers: nil)
|
|
6392
|
+
headers ||= ["Group", default_value_header]
|
|
6341
6393
|
pairs = @results.to_a
|
|
6342
6394
|
|
|
6343
6395
|
# Build table data
|
|
@@ -6361,6 +6413,20 @@ module Parse
|
|
|
6361
6413
|
|
|
6362
6414
|
private
|
|
6363
6415
|
|
|
6416
|
+
# Derive a human-readable column header from the aggregation operation.
|
|
6417
|
+
# @return [String] the default second-column header
|
|
6418
|
+
def default_value_header
|
|
6419
|
+
case @operation&.to_s
|
|
6420
|
+
when "count" then "Count"
|
|
6421
|
+
when "sum" then "Sum"
|
|
6422
|
+
when "average", "avg" then "Average"
|
|
6423
|
+
when "min" then "Min"
|
|
6424
|
+
when "max" then "Max"
|
|
6425
|
+
when "list" then "Items"
|
|
6426
|
+
else "Count"
|
|
6427
|
+
end
|
|
6428
|
+
end
|
|
6429
|
+
|
|
6364
6430
|
# Format group key for display
|
|
6365
6431
|
def format_group_key(key)
|
|
6366
6432
|
case key
|
|
@@ -6453,7 +6519,7 @@ module Parse
|
|
|
6453
6519
|
# @return [GroupedResult] a sortable result object.
|
|
6454
6520
|
def count
|
|
6455
6521
|
results = super
|
|
6456
|
-
GroupedResult.new(results)
|
|
6522
|
+
GroupedResult.new(results, "count")
|
|
6457
6523
|
end
|
|
6458
6524
|
|
|
6459
6525
|
# Sum a field for each group.
|
|
@@ -6461,7 +6527,7 @@ module Parse
|
|
|
6461
6527
|
# @return [GroupedResult] a sortable result object.
|
|
6462
6528
|
def sum(field)
|
|
6463
6529
|
results = super
|
|
6464
|
-
GroupedResult.new(results)
|
|
6530
|
+
GroupedResult.new(results, "sum")
|
|
6465
6531
|
end
|
|
6466
6532
|
|
|
6467
6533
|
# Calculate average of a field for each group.
|
|
@@ -6469,7 +6535,7 @@ module Parse
|
|
|
6469
6535
|
# @return [GroupedResult] a sortable result object.
|
|
6470
6536
|
def average(field)
|
|
6471
6537
|
results = super
|
|
6472
|
-
GroupedResult.new(results)
|
|
6538
|
+
GroupedResult.new(results, "average")
|
|
6473
6539
|
end
|
|
6474
6540
|
|
|
6475
6541
|
alias_method :avg, :average
|
|
@@ -6479,7 +6545,7 @@ module Parse
|
|
|
6479
6545
|
# @return [GroupedResult] a sortable result object.
|
|
6480
6546
|
def min(field)
|
|
6481
6547
|
results = super
|
|
6482
|
-
GroupedResult.new(results)
|
|
6548
|
+
GroupedResult.new(results, "min")
|
|
6483
6549
|
end
|
|
6484
6550
|
|
|
6485
6551
|
# Find maximum value of a field for each group.
|
|
@@ -6487,14 +6553,14 @@ module Parse
|
|
|
6487
6553
|
# @return [GroupedResult] a sortable result object.
|
|
6488
6554
|
def max(field)
|
|
6489
6555
|
results = super
|
|
6490
|
-
GroupedResult.new(results)
|
|
6556
|
+
GroupedResult.new(results, "max")
|
|
6491
6557
|
end
|
|
6492
6558
|
|
|
6493
6559
|
# Collect Parse::Object instances per group.
|
|
6494
6560
|
# @return [GroupedResult] a sortable result object.
|
|
6495
6561
|
def list
|
|
6496
6562
|
results = super
|
|
6497
|
-
GroupedResult.new(results)
|
|
6563
|
+
GroupedResult.new(results, "list")
|
|
6498
6564
|
end
|
|
6499
6565
|
end
|
|
6500
6566
|
|
|
@@ -7091,7 +7157,7 @@ module Parse
|
|
|
7091
7157
|
# @return [GroupedResult] a sortable result object.
|
|
7092
7158
|
def count
|
|
7093
7159
|
results = super
|
|
7094
|
-
GroupedResult.new(results)
|
|
7160
|
+
GroupedResult.new(results, "count")
|
|
7095
7161
|
end
|
|
7096
7162
|
|
|
7097
7163
|
# Sum a field for each time period.
|
|
@@ -7099,7 +7165,7 @@ module Parse
|
|
|
7099
7165
|
# @return [GroupedResult] a sortable result object.
|
|
7100
7166
|
def sum(field)
|
|
7101
7167
|
results = super
|
|
7102
|
-
GroupedResult.new(results)
|
|
7168
|
+
GroupedResult.new(results, "sum")
|
|
7103
7169
|
end
|
|
7104
7170
|
|
|
7105
7171
|
# Calculate average of a field for each time period.
|
|
@@ -7107,7 +7173,7 @@ module Parse
|
|
|
7107
7173
|
# @return [GroupedResult] a sortable result object.
|
|
7108
7174
|
def average(field)
|
|
7109
7175
|
results = super
|
|
7110
|
-
GroupedResult.new(results)
|
|
7176
|
+
GroupedResult.new(results, "average")
|
|
7111
7177
|
end
|
|
7112
7178
|
|
|
7113
7179
|
alias_method :avg, :average
|
|
@@ -7117,7 +7183,7 @@ module Parse
|
|
|
7117
7183
|
# @return [GroupedResult] a sortable result object.
|
|
7118
7184
|
def min(field)
|
|
7119
7185
|
results = super
|
|
7120
|
-
GroupedResult.new(results)
|
|
7186
|
+
GroupedResult.new(results, "min")
|
|
7121
7187
|
end
|
|
7122
7188
|
|
|
7123
7189
|
# Find maximum value of a field for each time period.
|
|
@@ -7125,7 +7191,7 @@ module Parse
|
|
|
7125
7191
|
# @return [GroupedResult] a sortable result object.
|
|
7126
7192
|
def max(field)
|
|
7127
7193
|
results = super
|
|
7128
|
-
GroupedResult.new(results)
|
|
7194
|
+
GroupedResult.new(results, "max")
|
|
7129
7195
|
end
|
|
7130
7196
|
end
|
|
7131
7197
|
end # Parse
|