Skip to content

Commit 8f56a7f

Browse files
committed
Enhance as_json for pointer collections and objects
Adds support for the :pointers_only option to PointerCollectionProxy#as_json, allowing serialization of full objects or pointers as needed. Parse::Object#as_json now always includes identification fields (objectId, className, __type, id) when using :only, unless :strict is true. Introduces :exclude as an alias for :except in as_json. Updates tests to cover new behaviors and documents changes in the changelog. Bumps version to 3.2.3.
1 parent 93dbfcc commit 8f56a7f

8 files changed

Lines changed: 763 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
## Parse-Stack Changelog
22

3+
### 3.2.3
4+
5+
#### Improvements
6+
7+
- **IMPROVED**: `PointerCollectionProxy#as_json` now supports the `pointers_only` option. By default it returns pointers (preserving backward compatibility), but you can set `pointers_only: false` to serialize objects with their fetched fields. This is useful when returning `has_many :through => :array` relationships in webhook responses.
8+
9+
When `pointers_only: false`:
10+
- Partially hydrated objects serialize only their fetched fields (no autofetch triggered)
11+
- Pointer-only objects (unfetched) remain as pointers
12+
- Fully hydrated objects serialize all their fields
13+
14+
```ruby
15+
# Default behavior - pointers for storage (backward compatible)
16+
capture.assets.as_json
17+
# => [{"__type"=>"Pointer", "className"=>"Asset", "objectId"=>"abc"}, ...]
18+
19+
# Serialize with fetched fields (no autofetch, pointers stay as pointers)
20+
capture.assets.as_json(pointers_only: false)
21+
# => [{"objectId"=>"abc", "file"=>{...}, "caption"=>"My photo", ...}, ...]
22+
23+
# In webhooks, manually override assets serialization:
24+
cloud_results.map do |capture|
25+
json = capture.as_json
26+
json['assets'] = capture.assets.as_json(pointers_only: false) if capture.assets.any?
27+
json
28+
end
29+
```
30+
31+
- **IMPROVED**: `Parse::Object#as_json` with `:only` option now automatically includes identification fields (`objectId`, `className`, `__type`, `id`) so serialized objects can always be properly identified. Use `strict: true` to disable this behavior for pure strict filtering.
32+
33+
```ruby
34+
# Default: identification fields are always included
35+
song.as_json(only: [:title, :artist])
36+
# => {"objectId"=>"abc", "className"=>"Song", "__type"=>"Object", "title"=>"...", "artist"=>"..."}
37+
38+
# With strict: true, only exactly specified fields are included
39+
song.as_json(only: [:title, :artist], strict: true)
40+
# => {"title"=>"...", "artist"=>"..."}
41+
```
42+
43+
- **NEW**: Added `:exclude` as an alias for `:except` in `as_json` for more intuitive field exclusion.
44+
45+
```ruby
46+
# All three are equivalent:
47+
song.as_json(except: [:acl, :created_at])
48+
song.as_json(exclude_keys: [:acl, :created_at])
49+
song.as_json(exclude: [:acl, :created_at])
50+
```
51+
352
### 3.2.2
453

554
#### Improvements

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (3.2.1)
4+
parse-stack (3.2.3)
55
activemodel (>= 5, < 9)
66
activesupport (>= 5, < 9)
77
faraday (~> 2.0)

lib/parse/model/associations/pointer_collection_proxy.rb

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,39 @@ def fetch
9696
collection.fetch_objects
9797
end
9898

99-
# Encode the collection as a JSON object of Parse::Pointers.
99+
# Encode the collection as JSON.
100+
# By default, returns Parse::Pointers for backward compatibility when saving.
101+
# Set `pointers_only: false` to get full hydrated objects for API responses.
102+
# @param opts [Hash] options for serialization
103+
# @option opts [Boolean] :pointers_only (true) When true (default), converts all
104+
# Parse objects to pointer format. Set to false to serialize full objects.
105+
# @option opts [Boolean] :only_fetched (true) When true (default when pointers_only
106+
# is false), only serialize fields that were actually fetched. This prevents
107+
# autofetch from being triggered during serialization of partially hydrated objects.
108+
# @example Default - pointers for storage
109+
# capture.assets.as_json
110+
# # => [{"__type"=>"Pointer", "className"=>"Asset", "objectId"=>"abc"}, ...]
111+
# @example Full objects for API responses (only fetched fields, no autofetch)
112+
# capture.assets.as_json(pointers_only: false)
113+
# # => [{"objectId"=>"abc", "file"=>{...}, "caption"=>"...", ...}, ...]
100114
def as_json(opts = nil)
101-
parse_pointers.as_json(opts)
115+
opts ||= {}
116+
117+
# Normalize string keys to symbols to avoid conflicts with defaults
118+
opts = opts.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
119+
120+
# Check if pointers_only was explicitly set, otherwise default to true
121+
pointers_only = opts.fetch(:pointers_only, true)
122+
123+
# Default to pointers_only: true for backward compatibility
124+
# When pointers_only is false, default only_fetched to true to prevent
125+
# autofetch during serialization of partially hydrated objects
126+
defaults = { pointers_only: true }
127+
unless pointers_only
128+
defaults[:only_fetched] = true unless opts.key?(:only_fetched)
129+
end
130+
opts = defaults.merge(opts)
131+
super(opts)
102132
end
103133
end
104134
end

lib/parse/model/object.rb

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -733,20 +733,41 @@ def self.roles_for_user(user)
733733

734734
# @!endgroup
735735

736+
# Core identification fields that are always included in serialization
737+
# unless strict: true is specified
738+
IDENTIFICATION_FIELDS = %w[id objectId __type className].freeze
739+
736740
# @return [Hash] a json-hash representing this object.
737741
# @param opts [Hash] options for serialization
738742
# @option opts [Boolean] :only_fetched when true (or when Parse.serialize_only_fetched_fields
739743
# is true and this option is not explicitly set to false), only serialize fields that
740744
# were fetched for partially fetched objects. This prevents autofetch during serialization.
741-
# @option opts [Array<Symbol,String>] :only limit serialization to these fields
745+
# @option opts [Array<Symbol,String>] :only limit serialization to these fields. By default,
746+
# identification fields (objectId, className, __type, id) are always included for proper
747+
# object identification. Use strict: true to disable this behavior.
742748
# @option opts [Array<Symbol,String>] :except exclude these fields from serialization
743749
# @option opts [Array<Symbol,String>] :exclude_keys alias for :except
750+
# @option opts [Array<Symbol,String>] :exclude alias for :except
751+
# @option opts [Boolean] :strict when true with :only, performs strict filtering without
752+
# automatically including identification fields. Default is false.
744753
def as_json(opts = nil)
745754
opts ||= {}
746755

747-
# Normalize :exclude_keys to :except (alias support)
748-
if opts[:exclude_keys] && !opts[:except]
749-
opts = opts.merge(except: opts[:exclude_keys])
756+
# Normalize :exclude_keys and :exclude to :except (alias support)
757+
if !opts[:except]
758+
if opts[:exclude_keys]
759+
opts = opts.merge(except: opts[:exclude_keys])
760+
elsif opts[:exclude]
761+
opts = opts.merge(except: opts[:exclude])
762+
end
763+
end
764+
765+
# When :only is specified without :strict, automatically include identification fields
766+
# so the serialized object can be properly identified
767+
if opts[:only] && !opts[:strict]
768+
only_keys = Array(opts[:only]).map(&:to_s)
769+
only_keys |= IDENTIFICATION_FIELDS
770+
opts = opts.merge(only: only_keys)
750771
end
751772

752773
# For selectively fetched objects (partial fetch), serialize only the fetched fields.
@@ -764,7 +785,8 @@ def as_json(opts = nil)
764785
# Use the local field names which match the attribute methods
765786
only_keys = fetched_keys.map(&:to_s)
766787
# Always include Parse metadata fields for proper object identification
767-
only_keys |= %w[id objectId __type className created_at updated_at]
788+
only_keys |= IDENTIFICATION_FIELDS
789+
only_keys |= %w[created_at updated_at]
768790
opts = opts.merge(only: only_keys)
769791
end
770792

lib/parse/stack/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ module Parse
66
# The Parse Server SDK for Ruby
77
module Stack
88
# The current version.
9-
VERSION = "3.2.2"
9+
VERSION = "3.2.3"
1010
end
1111
end

test/lib/parse/collection_proxy_as_json_test.rb

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,204 @@ def test_as_json_pointers_only_with_string_key
170170
assert_equal "Pointer", result[0]["__type"]
171171
end
172172
end
173+
174+
# Test model for pointer collection proxy testing with has_many :through => :array
175+
class PointerCollectionTestCapture < Parse::Object
176+
parse_class "PointerCollectionTestCapture"
177+
property :title, :string
178+
has_many :assets, through: :array, as: :pointer_collection_test_asset
179+
end
180+
181+
class PointerCollectionTestAsset < Parse::Object
182+
parse_class "PointerCollectionTestAsset"
183+
property :caption, :string
184+
property :file_url, :string
185+
property :thumbnail_url, :string
186+
end
187+
188+
class PointerCollectionProxyAsJsonTest < Minitest::Test
189+
def setup
190+
# Create "fetched" objects with timestamps (not pointer state)
191+
@asset1 = PointerCollectionTestAsset.new(
192+
"objectId" => "asset123",
193+
"caption" => "Photo 1",
194+
"fileUrl" => "https://example.com/photo1.jpg",
195+
"thumbnailUrl" => "https://example.com/thumb1.jpg",
196+
"createdAt" => "2024-01-01T00:00:00.000Z",
197+
"updatedAt" => "2024-01-01T00:00:00.000Z"
198+
)
199+
@asset2 = PointerCollectionTestAsset.new(
200+
"objectId" => "asset456",
201+
"caption" => "Photo 2",
202+
"fileUrl" => "https://example.com/photo2.jpg",
203+
"thumbnailUrl" => "https://example.com/thumb2.jpg",
204+
"createdAt" => "2024-01-01T00:00:00.000Z",
205+
"updatedAt" => "2024-01-01T00:00:00.000Z"
206+
)
207+
@pointer_only = PointerCollectionTestAsset.new("asset789") # Pointer-only (just objectId)
208+
end
209+
210+
# === Default behavior (backward compatible - returns pointers) ===
211+
212+
def test_as_json_default_returns_pointers
213+
proxy = Parse::PointerCollectionProxy.new([@asset1, @asset2])
214+
215+
result = proxy.as_json
216+
217+
# Default: should return pointers for backward compatibility
218+
assert_equal 2, result.length
219+
result.each do |item|
220+
assert_equal "Pointer", item["__type"]
221+
assert_equal "PointerCollectionTestAsset", item["className"]
222+
end
223+
end
224+
225+
def test_as_json_default_with_single_object
226+
proxy = Parse::PointerCollectionProxy.new([@asset1])
227+
228+
result = proxy.as_json
229+
230+
assert_equal 1, result.length
231+
assert_equal "Pointer", result[0]["__type"]
232+
assert_equal "PointerCollectionTestAsset", result[0]["className"]
233+
assert_equal "asset123", result[0]["objectId"]
234+
end
235+
236+
# === pointers_only: false (serialize full objects) ===
237+
238+
def test_as_json_pointers_only_false_returns_full_objects
239+
proxy = Parse::PointerCollectionProxy.new([@asset1, @asset2])
240+
241+
result = proxy.as_json(pointers_only: false)
242+
243+
# Should serialize full objects, not pointers
244+
assert_equal 2, result.length
245+
result.each do |item|
246+
assert item.is_a?(Hash)
247+
# Should NOT have __type: Pointer
248+
refute_equal "Pointer", item["__type"]
249+
# Should have objectId
250+
assert item["objectId"].present?
251+
end
252+
end
253+
254+
def test_as_json_pointers_only_false_includes_fetched_fields
255+
proxy = Parse::PointerCollectionProxy.new([@asset1])
256+
257+
result = proxy.as_json(pointers_only: false)
258+
259+
assert_equal 1, result.length
260+
item = result[0]
261+
262+
# Should include the fields that were set
263+
assert_equal "asset123", item["objectId"]
264+
assert_equal "Photo 1", item["caption"]
265+
assert_equal "https://example.com/photo1.jpg", item["fileUrl"]
266+
assert_equal "https://example.com/thumb1.jpg", item["thumbnailUrl"]
267+
end
268+
269+
def test_as_json_pointers_only_false_with_pointer_only_object_returns_pointer
270+
proxy = Parse::PointerCollectionProxy.new([@pointer_only])
271+
272+
result = proxy.as_json(pointers_only: false)
273+
274+
# Pointer-only objects should still return as pointers
275+
assert_equal 1, result.length
276+
item = result[0]
277+
assert_equal "Pointer", item["__type"]
278+
assert_equal "PointerCollectionTestAsset", item["className"]
279+
assert_equal "asset789", item["objectId"]
280+
end
281+
282+
def test_as_json_pointers_only_false_mixed_hydrated_and_pointers
283+
proxy = Parse::PointerCollectionProxy.new([@asset1, @pointer_only, @asset2])
284+
285+
result = proxy.as_json(pointers_only: false)
286+
287+
assert_equal 3, result.length
288+
289+
# First item: hydrated object
290+
assert_equal "asset123", result[0]["objectId"]
291+
assert_equal "Photo 1", result[0]["caption"]
292+
refute_equal "Pointer", result[0]["__type"]
293+
294+
# Second item: pointer-only, should remain a pointer
295+
assert_equal "Pointer", result[1]["__type"]
296+
assert_equal "asset789", result[1]["objectId"]
297+
298+
# Third item: hydrated object
299+
assert_equal "asset456", result[2]["objectId"]
300+
assert_equal "Photo 2", result[2]["caption"]
301+
refute_equal "Pointer", result[2]["__type"]
302+
end
303+
304+
# === pointers_only: true (explicit) ===
305+
306+
def test_as_json_pointers_only_true_returns_pointers
307+
proxy = Parse::PointerCollectionProxy.new([@asset1, @asset2])
308+
309+
result = proxy.as_json(pointers_only: true)
310+
311+
assert_equal 2, result.length
312+
result.each do |item|
313+
assert_equal "Pointer", item["__type"]
314+
end
315+
end
316+
317+
# === only_fetched option (prevents autofetch) ===
318+
319+
def test_as_json_pointers_only_false_defaults_only_fetched_true
320+
# Create a partially fetched object by setting selective keys
321+
partial_asset = PointerCollectionTestAsset.new(
322+
"objectId" => "partial123",
323+
"caption" => "Partial Photo"
324+
)
325+
# Mark it as selectively fetched (uses @_fetched_keys internally)
326+
partial_asset.instance_variable_set(:@_fetched_keys, Set.new([:id, :caption]))
327+
328+
proxy = Parse::PointerCollectionProxy.new([partial_asset])
329+
330+
# With pointers_only: false, only_fetched defaults to true
331+
result = proxy.as_json(pointers_only: false)
332+
333+
assert_equal 1, result.length
334+
item = result[0]
335+
# Should include fetched fields
336+
assert_equal "partial123", item["objectId"]
337+
assert_equal "Partial Photo", item["caption"]
338+
end
339+
340+
def test_as_json_can_override_only_fetched
341+
proxy = Parse::PointerCollectionProxy.new([@asset1])
342+
343+
# Explicitly set only_fetched: false
344+
result = proxy.as_json(pointers_only: false, only_fetched: false)
345+
346+
assert_equal 1, result.length
347+
assert_equal "Photo 1", result[0]["caption"]
348+
end
349+
350+
# === String option keys work ===
351+
352+
def test_as_json_pointers_only_false_with_string_key
353+
proxy = Parse::PointerCollectionProxy.new([@asset1])
354+
355+
result = proxy.as_json("pointers_only" => false)
356+
357+
assert_equal 1, result.length
358+
refute_equal "Pointer", result[0]["__type"]
359+
assert_equal "Photo 1", result[0]["caption"]
360+
end
361+
362+
# === Empty collection ===
363+
364+
def test_as_json_empty_collection
365+
proxy = Parse::PointerCollectionProxy.new([])
366+
367+
result_default = proxy.as_json
368+
result_full = proxy.as_json(pointers_only: false)
369+
370+
assert_equal [], result_default
371+
assert_equal [], result_full
372+
end
373+
end

0 commit comments

Comments
 (0)