Skip to content

Commit fac88fd

Browse files
committed
Add partial fetch tracking for Parse objects
Implements a system to track partially fetched Parse objects, including methods to check fetched fields, autofetch missing fields, and support for nested partial fetches via includes. Updates object construction, associations, and query decoding to propagate partial fetch state. Adds comprehensive integration tests for partial fetch behavior. Bumps version to 2.1.0.
1 parent 51ec827 commit fac88fd

8 files changed

Lines changed: 873 additions & 13 deletions

File tree

CHANGELOG.md

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

3+
### 2.1.0
4+
5+
#### Partial Fetch Tracking System
6+
- **NEW**: Partial fetch tracking for objects fetched with specific `keys` parameter
7+
- **NEW**: `partially_fetched?` method to check if object was fetched with limited fields
8+
- **NEW**: `fetched_keys` / `fetched_keys=` methods to get/set the array of fetched field names
9+
- **NEW**: `field_was_fetched?(key)` method to check if a specific field was included in the fetch
10+
- **NEW**: Autofetch triggers automatically when accessing unfetched fields on partially fetched objects
11+
- **NEW**: Nested partial fetch tracking for included objects via `include:` parameter
12+
- **NEW**: `nested_fetched_keys` / `nested_keys_for(field)` methods for tracking nested object fields
13+
- **NEW**: `parse_includes_to_nested_keys` helper parses include patterns like `["team.time_zone", "team.name"]`
14+
- **FIXED**: Objects fetched with `keys:` parameter no longer have dirty tracking for fields with default values
15+
- **FIXED**: `clear_changes!` now called after `apply_defaults!` to prevent false dirty tracking
16+
- **IMPROVED**: Before-save hooks can now reliably access unfetched fields (triggers autofetch)
17+
- **IMPROVED**: Saving partially fetched objects only updates actually changed fields, not default values
18+
319
### 2.0.9
420

521
- **FIXED**: `Query#where` method now routes through `conditions` to properly handle special keywords like `keys:`, `include:`, `limit:`, etc. when chaining (e.g., `Model.query.where(keys: [...])`)

lib/parse/model/associations/belongs_to.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ def belongs_to(key, opts = {})
165165
# hash, lets try to buid a Pointer of that type.
166166

167167
if val.is_a?(Hash) && (val["__type"] == "Pointer" || val["__type"] == "Object")
168-
val = Parse::Object.build val, (val[Parse::Model::KEY_CLASS_NAME] || klassName)
168+
# Get nested fetched keys for this field if available
169+
nested_keys = nested_keys_for(key)
170+
val = Parse::Object.build val, (val[Parse::Model::KEY_CLASS_NAME] || klassName), fetched_keys: nested_keys
169171
instance_variable_set ivar, val
170172
end
171173
val
@@ -189,7 +191,9 @@ def belongs_to(key, opts = {})
189191
if val == Parse::Properties::DELETE_OP
190192
val = nil
191193
elsif val.is_a?(Hash) && (val["__type"] == "Pointer" || val["__type"] == "Object")
192-
val = Parse::Object.build val, (val[Parse::Model::KEY_CLASS_NAME] || klassName)
194+
# Get nested fetched keys for this field if available
195+
nested_keys = nested_keys_for(key)
196+
val = Parse::Object.build val, (val[Parse::Model::KEY_CLASS_NAME] || klassName), fetched_keys: nested_keys
193197
end
194198

195199
if track == true

lib/parse/model/core/fetching.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ def fetch!(opts = {})
4242

4343
# take the result hash and apply it to the attributes.
4444
apply_attributes!(result, dirty_track: false)
45+
46+
# Clear partial fetch tracking - object is now fully fetched
47+
@_fetched_keys = nil
48+
@_nested_fetched_keys = nil
49+
4550
begin
4651
clear_changes!
4752
rescue => e
@@ -78,14 +83,16 @@ def fetch_object
7883

7984
# Autofetches the object based on a key that is not part {Parse::Properties::BASE_KEYS}.
8085
# If the key is not a Parse standard key, and the current object is in a
81-
# Pointer state, then fetch the data related to this record from the Parse
82-
# data store.
86+
# Pointer state or was partially fetched, then fetch the data related to
87+
# this record from the Parse data store.
8388
# @param key [String] the name of the attribute being accessed.
8489
# @return [Boolean]
8590
def autofetch!(key)
8691
key = key.to_sym
8792
@fetch_lock ||= false
88-
if @fetch_lock != true && pointer? && key != :acl && Parse::Properties::BASE_KEYS.include?(key) == false && respond_to?(:fetch)
93+
# Autofetch if object is a pointer OR was partially fetched
94+
needs_fetch = pointer? || partially_fetched?
95+
if @fetch_lock != true && needs_fetch && key != :acl && Parse::Properties::BASE_KEYS.include?(key) == false && respond_to?(:fetch)
8996
#puts "AutoFetching Triggerd by: #{self.class}.#{key} (#{id})"
9097
@fetch_lock = true
9198
send :fetch

lib/parse/model/core/properties.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,10 @@ def property(key, data_type = :string, **opts)
273273

274274
# If the value is nil and this current Parse::Object instance is a pointer?
275275
# then someone is calling the getter for this, which means they probably want
276-
# its value - so let's go turn this pointer into a full object record
277-
if value.nil? && pointer?
276+
# its value - so let's go turn this pointer into a full object record.
277+
# Also autofetch if object was partially fetched and this field wasn't included.
278+
should_autofetch = value.nil? && (pointer? || (partially_fetched? && !field_was_fetched?(key)))
279+
if should_autofetch
278280
# call autofetch to fetch the entire record
279281
# and then get the ivar again cause it might have been updated.
280282
autofetch!(key)

lib/parse/model/object.rb

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,13 @@ def initialize(opts = {})
311311
# ACL.typecast will auto convert of Parse::ACL
312312
self.acl = self.class.default_acls.as_json if self.acl.nil?
313313

314-
clear_changes! if @id.present? #then it was an import
315-
316314
# do not apply defaults on a pointer because it will stop it from being
317315
# a pointer and will cause its field to be autofetched (for sync)
318316
apply_defaults! unless pointer?
317+
318+
# clear changes AFTER applying defaults, so fields set by defaults
319+
# are not marked dirty when fetching with specific keys
320+
clear_changes! if @id.present? #then it was an import
319321
# do not call super since it is Pointer subclass
320322
end
321323

@@ -382,6 +384,77 @@ def existed?
382384
created_at != updated_at
383385
end
384386

387+
# Returns whether this object was fetched with specific keys (partial fetch).
388+
# When partially fetched, accessing unfetched fields will trigger an autofetch.
389+
# @return [Boolean] true if the object was fetched with specific keys.
390+
def partially_fetched?
391+
@_fetched_keys.present? && @_fetched_keys.any?
392+
end
393+
394+
# Returns the array of keys that were fetched for this object.
395+
# Empty array means the object was fully fetched.
396+
# @return [Array<Symbol>] the keys that were fetched.
397+
def fetched_keys
398+
@_fetched_keys || []
399+
end
400+
401+
# Sets the fetched keys for this object. Used internally when building
402+
# objects from partial fetch queries.
403+
# @param keys [Array] the keys that were fetched
404+
# @return [Array] the stored keys
405+
def fetched_keys=(keys)
406+
if keys.nil? || keys.empty?
407+
@_fetched_keys = nil
408+
else
409+
# Always include :id and convert to symbols
410+
@_fetched_keys = keys.map { |k| Parse::Query.format_field(k).to_sym }
411+
@_fetched_keys << :id unless @_fetched_keys.include?(:id)
412+
@_fetched_keys << :objectId unless @_fetched_keys.include?(:objectId)
413+
@_fetched_keys.uniq!
414+
end
415+
@_fetched_keys
416+
end
417+
418+
# Returns whether a specific field was fetched for this object.
419+
# Base keys (id, created_at, updated_at) are always considered fetched.
420+
# @param key [Symbol, String] the field name to check
421+
# @return [Boolean] true if the field was fetched or if object is fully fetched.
422+
def field_was_fetched?(key)
423+
# If not partially fetched, all fields are considered fetched
424+
return true unless partially_fetched?
425+
426+
key = key.to_sym
427+
# Base keys are always considered fetched
428+
return true if Parse::Properties::BASE_KEYS.include?(key)
429+
return true if key == :acl || key == :ACL
430+
431+
# Check both local key and remote field name
432+
remote_key = self.field_map[key]
433+
fetched_keys.include?(key) || (remote_key && fetched_keys.include?(remote_key))
434+
end
435+
436+
# Returns the nested fetched keys map for building nested objects.
437+
# @return [Hash] map of field names to their fetched keys
438+
def nested_fetched_keys
439+
@_nested_fetched_keys || {}
440+
end
441+
442+
# Sets the nested fetched keys map for building nested objects.
443+
# @param keys_map [Hash] map of field names to their fetched keys
444+
# @return [Hash] the stored map
445+
def nested_fetched_keys=(keys_map)
446+
@_nested_fetched_keys = keys_map.is_a?(Hash) ? keys_map : nil
447+
end
448+
449+
# Gets the fetched keys for a specific nested field.
450+
# @param field_name [Symbol, String] the field name
451+
# @return [Array, nil] the fetched keys for the nested object, or nil if not specified
452+
def nested_keys_for(field_name)
453+
return nil unless @_nested_fetched_keys.present?
454+
field_name = field_name.to_sym
455+
@_nested_fetched_keys[field_name]
456+
end
457+
385458
# Run after_create callbacks for this object.
386459
# This method is called by webhook handlers when an object is created.
387460
# @return [Boolean] true if callbacks executed successfully
@@ -485,8 +558,10 @@ def clear_attribute_change!(atts)
485558
# post = Post.build({"title" => "My Title"})
486559
# @param json [Hash] a JSON hash that contains a Parse object.
487560
# @param table [String] the Parse class for this hash. If not passed it will be detected.
561+
# @param fetched_keys [Array] optional array of keys that were fetched (for partial fetch tracking).
562+
# @param nested_fetched_keys [Hash] optional map of field names to their fetched keys for nested objects.
488563
# @return [Parse::Object] an instance of the Parse subclass
489-
def self.build(json, table = nil)
564+
def self.build(json, table = nil, fetched_keys: nil, nested_fetched_keys: nil)
490565
className = table
491566
className ||= (json[Parse::Model::KEY_CLASS_NAME] || json[:className]) if json.is_a?(Hash)
492567
if json.is_a?(Hash) && json["error"].present? && json["code"].present?
@@ -501,7 +576,12 @@ def self.build(json, table = nil)
501576
if klass.present?
502577
# when creating objects from Parse JSON data, don't use dirty tracking since
503578
# we are considering these objects as "pristine"
504-
o = klass.new(json)
579+
o = klass.allocate
580+
# Set nested fetched keys BEFORE apply_attributes! so nested objects can use them
581+
o.instance_variable_set(:@_nested_fetched_keys, nested_fetched_keys) if nested_fetched_keys.present?
582+
o.send(:initialize, json)
583+
# Set fetched keys for partial fetch tracking
584+
o.fetched_keys = fetched_keys if fetched_keys.present?
505585
else
506586
o = Parse::Pointer.new className, (json[Parse::Model::OBJECT_ID] || json[:objectId])
507587
end

lib/parse/query.rb

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1375,7 +1375,53 @@ def all(expressions = { limit: :max }, &block)
13751375
# @param list [Array<Hash>] a list of Parse JSON hashes
13761376
# @return [Array<Parse::Object>] an array of Parse::Object subclasses.
13771377
def decode(list)
1378-
list.map { |m| Parse::Object.build(m, @table) }.compact
1378+
# Pass fetched keys for partial fetch tracking (only if keys were specified)
1379+
fetch_keys = @keys.present? && @keys.any? ? @keys : nil
1380+
1381+
# Parse includes to build nested fetched keys map
1382+
nested_keys = parse_includes_to_nested_keys(@includes) if @includes.present?
1383+
1384+
list.map { |m| Parse::Object.build(m, @table, fetched_keys: fetch_keys, nested_fetched_keys: nested_keys) }.compact
1385+
end
1386+
1387+
# Parses include patterns to build a map of nested fetched keys.
1388+
# For example, ["team.time_zone", "team.name", "author"] becomes:
1389+
# { team: [:time_zone, :name], author: [] }
1390+
# @param includes [Array<Symbol>] the include patterns
1391+
# @return [Hash] a map of nested field names to their fetched keys
1392+
def parse_includes_to_nested_keys(includes)
1393+
return {} if includes.nil? || includes.empty?
1394+
1395+
nested_map = {}
1396+
1397+
includes.each do |include_path|
1398+
parts = include_path.to_s.split('.')
1399+
next if parts.empty?
1400+
1401+
# First part is the field name on the parent object
1402+
field_name = parts.first.to_sym
1403+
1404+
# Initialize the array for this field if not already
1405+
nested_map[field_name] ||= []
1406+
1407+
# If there are more parts, they are fields on the nested object
1408+
if parts.length > 1
1409+
nested_field = parts[1].to_sym
1410+
nested_map[field_name] << nested_field unless nested_map[field_name].include?(nested_field)
1411+
1412+
# Handle deeper nesting (e.g., team.manager.name)
1413+
if parts.length > 2
1414+
# For now, we'll create a key for the second level too
1415+
# This would need recursive handling for deeper nesting
1416+
second_level_field = parts[1].to_sym
1417+
nested_map[second_level_field] ||= []
1418+
nested_field = parts[2].to_sym
1419+
nested_map[second_level_field] << nested_field unless nested_map[second_level_field].include?(nested_field)
1420+
end
1421+
end
1422+
end
1423+
1424+
nested_map
13791425
end
13801426

13811427
# Builds Parse::Pointer objects based on the set of Parse JSON hashes in an array.

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 = "2.0.9"
9+
VERSION = "2.1.0"
1010
end
1111
end

0 commit comments

Comments
 (0)