Skip to content

Commit 106f504

Browse files
committed
Add partial fetch controls and error handling
Introduces methods to disable, enable, and check autofetch on Parse objects, and adds UnfetchedFieldAccessError for unfetched field access when autofetch is disabled. Improves partial fetch state management, deepens nested key parsing, and adds extensive unit tests for partial fetch functionality.
1 parent fac88fd commit 106f504

8 files changed

Lines changed: 451 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@
1616
- **IMPROVED**: Before-save hooks can now reliably access unfetched fields (triggers autofetch)
1717
- **IMPROVED**: Saving partially fetched objects only updates actually changed fields, not default values
1818

19+
#### Code Quality & Security Improvements
20+
- **NEW**: `disable_autofetch!` method to prevent automatic network requests on an instance
21+
- **NEW**: `enable_autofetch!` method to re-enable autofetch
22+
- **NEW**: `autofetch_disabled?` method to check if autofetch is disabled
23+
- **NEW**: `clear_partial_fetch_state!` public method for clearing partial fetch tracking
24+
- **NEW**: `Parse::UnfetchedFieldAccessError` raised when accessing unfetched fields with autofetch disabled
25+
- **FIXED**: Inconsistent state in `build` - both `nested_fetched_keys` and `fetched_keys` now set before `initialize`
26+
- **FIXED**: Deep nesting support - `parse_includes_to_nested_keys` now handles arbitrary depth (e.g., `a.b.c.d`)
27+
- **FIXED**: String/symbol mismatch in `field_was_fetched?` - remote_key now converted to symbol
28+
- **IMPROVED**: `fetched_keys` getter returns frozen duplicate to prevent external mutation
29+
- **IMPROVED**: Autofetch prevented during `apply_defaults!` when object is partially fetched
30+
- **IMPROVED**: Info-level logging when autofetch is triggered (shows class, id, and field that triggered fetch)
31+
32+
#### Testing
33+
- **NEW**: 34 unit tests for partial fetch functionality (no Docker required)
34+
- **NEW**: 18 integration tests for partial fetch with real Parse Server
35+
1936
### 2.0.9
2037

2138
- **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/core/actions.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,11 +723,13 @@ def save(session: nil, autoraise: false, force: false)
723723
success = update_relations
724724
if success
725725
changes_applied!
726+
clear_partial_fetch_state!
726727
elsif self.class.raise_on_save_failure || autoraise.present?
727728
raise Parse::RecordNotSaved.new(self), "Failed updating relations. #{self.parse_class} partially saved."
728729
end
729730
else
730731
changes_applied!
732+
clear_partial_fetch_state!
731733
end
732734
elsif self.class.raise_on_save_failure || autoraise.present?
733735
raise Parse::RecordNotSaved.new(self), "Failed to create or save attributes. #{self.parse_class} was not saved."

lib/parse/model/core/errors.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,17 @@
55
module Parse
66
# An abstract parent class for all Parse::Error types.
77
class Error < StandardError; end
8+
9+
# Raised when attempting to access a field that was not fetched on a partially
10+
# fetched object when autofetch has been disabled.
11+
class UnfetchedFieldAccessError < Error
12+
attr_reader :field_name, :object_class
13+
14+
def initialize(field_name, object_class)
15+
@field_name = field_name
16+
@object_class = object_class
17+
super("Attempted to access unfetched field '#{field_name}' on #{object_class} with autofetch disabled. " \
18+
"Either fetch the object first, include this field in the keys parameter, or enable autofetch.")
19+
end
20+
end
821
end

lib/parse/model/core/fetching.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,16 @@ def autofetch!(key)
9191
key = key.to_sym
9292
@fetch_lock ||= false
9393
# Autofetch if object is a pointer OR was partially fetched
94+
# Skip if autofetch is disabled for this instance
9495
needs_fetch = pointer? || partially_fetched?
95-
if @fetch_lock != true && needs_fetch && key != :acl && Parse::Properties::BASE_KEYS.include?(key) == false && respond_to?(:fetch)
96-
#puts "AutoFetching Triggerd by: #{self.class}.#{key} (#{id})"
96+
can_fetch = @fetch_lock != true && !autofetch_disabled? && needs_fetch && key != :acl && Parse::Properties::BASE_KEYS.include?(key) == false && respond_to?(:fetch)
97+
if can_fetch
98+
# Log info about autofetch being triggered
99+
if partially_fetched?
100+
puts "[Parse::Autofetch] Fetching #{self.class}##{id} - field :#{key} was not included in partial fetch"
101+
else
102+
puts "[Parse::Autofetch] Fetching #{self.class}##{id} - pointer accessed field :#{key}"
103+
end
97104
@fetch_lock = true
98105
send :fetch
99106
@fetch_lock = false

lib/parse/model/core/properties.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ def property(key, data_type = :string, **opts)
277277
# Also autofetch if object was partially fetched and this field wasn't included.
278278
should_autofetch = value.nil? && (pointer? || (partially_fetched? && !field_was_fetched?(key)))
279279
if should_autofetch
280+
# If autofetch is disabled and we're accessing an unfetched field on a
281+
# partially fetched object, raise an error to make the issue explicit
282+
if autofetch_disabled? && partially_fetched? && !field_was_fetched?(key)
283+
raise Parse::UnfetchedFieldAccessError.new(key, self.class.name)
284+
end
280285
# call autofetch to fetch the entire record
281286
# and then get the ivar again cause it might have been updated.
282287
autofetch!(key)

lib/parse/model/object.rb

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,12 @@ def initialize(opts = {})
313313

314314
# do not apply defaults on a pointer because it will stop it from being
315315
# a pointer and will cause its field to be autofetched (for sync)
316-
apply_defaults! unless pointer?
316+
# Use fetch_lock to prevent autofetch during default application when partially fetched
317+
if !pointer?
318+
@fetch_lock = true if partially_fetched?
319+
apply_defaults!
320+
@fetch_lock = false
321+
end
317322

318323
# clear changes AFTER applying defaults, so fields set by defaults
319324
# are not marked dirty when fetching with specific keys
@@ -388,14 +393,34 @@ def existed?
388393
# When partially fetched, accessing unfetched fields will trigger an autofetch.
389394
# @return [Boolean] true if the object was fetched with specific keys.
390395
def partially_fetched?
391-
@_fetched_keys.present? && @_fetched_keys.any?
396+
@_fetched_keys&.any? || false
392397
end
393398

394399
# Returns the array of keys that were fetched for this object.
395400
# Empty array means the object was fully fetched.
401+
# Returns a frozen duplicate to prevent external mutation.
396402
# @return [Array<Symbol>] the keys that were fetched.
397403
def fetched_keys
398-
@_fetched_keys || []
404+
(@_fetched_keys || []).dup.freeze
405+
end
406+
407+
# Disables autofetch for this object instance.
408+
# Useful for preventing automatic network requests.
409+
# @return [void]
410+
def disable_autofetch!
411+
@_autofetch_disabled = true
412+
end
413+
414+
# Enables autofetch for this object instance (default behavior).
415+
# @return [void]
416+
def enable_autofetch!
417+
@_autofetch_disabled = false
418+
end
419+
420+
# Returns whether autofetch is disabled for this instance.
421+
# @return [Boolean] true if autofetch is disabled
422+
def autofetch_disabled?
423+
@_autofetch_disabled == true
399424
end
400425

401426
# Sets the fetched keys for this object. Used internally when building
@@ -429,8 +454,9 @@ def field_was_fetched?(key)
429454
return true if key == :acl || key == :ACL
430455

431456
# 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))
457+
# Convert remote_key to symbol for consistent comparison
458+
remote_key = self.field_map[key]&.to_sym
459+
@_fetched_keys.include?(key) || (remote_key && @_fetched_keys.include?(remote_key))
434460
end
435461

436462
# Returns the nested fetched keys map for building nested objects.
@@ -455,6 +481,14 @@ def nested_keys_for(field_name)
455481
@_nested_fetched_keys[field_name]
456482
end
457483

484+
# Clears all partial fetch tracking state.
485+
# Called after successful save since server returns updated object.
486+
# @return [void]
487+
def clear_partial_fetch_state!
488+
@_fetched_keys = nil
489+
@_nested_fetched_keys = nil
490+
end
491+
458492
# Run after_create callbacks for this object.
459493
# This method is called by webhook handlers when an object is created.
460494
# @return [Boolean] true if callbacks executed successfully
@@ -577,11 +611,20 @@ def self.build(json, table = nil, fetched_keys: nil, nested_fetched_keys: nil)
577611
# when creating objects from Parse JSON data, don't use dirty tracking since
578612
# we are considering these objects as "pristine"
579613
o = klass.allocate
580-
# Set nested fetched keys BEFORE apply_attributes! so nested objects can use them
614+
615+
# Set BOTH nested_fetched_keys AND fetched_keys BEFORE initialize
616+
# to ensure partially_fetched? returns correct value during attribute application
581617
o.instance_variable_set(:@_nested_fetched_keys, nested_fetched_keys) if nested_fetched_keys.present?
618+
if fetched_keys.present?
619+
# Process fetched_keys like the setter does - convert to symbols and include :id
620+
processed_keys = fetched_keys.map { |k| Parse::Query.format_field(k).to_sym }
621+
processed_keys << :id unless processed_keys.include?(:id)
622+
processed_keys << :objectId unless processed_keys.include?(:objectId)
623+
processed_keys.uniq!
624+
o.instance_variable_set(:@_fetched_keys, processed_keys)
625+
end
626+
582627
o.send(:initialize, json)
583-
# Set fetched keys for partial fetch tracking
584-
o.fetched_keys = fetched_keys if fetched_keys.present?
585628
else
586629
o = Parse::Pointer.new className, (json[Parse::Model::OBJECT_ID] || json[:objectId])
587630
end

lib/parse/query.rb

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,8 +1385,9 @@ def decode(list)
13851385
end
13861386

13871387
# 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: [] }
1388+
# Handles arbitrary nesting depth (e.g., "a.b.c.d" creates entries for a, b, c).
1389+
# For example, ["team.time_zone", "team.name", "author", "team.manager.email"] becomes:
1390+
# { team: [:time_zone, :name, :manager], author: [], manager: [:email] }
13901391
# @param includes [Array<Symbol>] the include patterns
13911392
# @return [Hash] a map of nested field names to their fetched keys
13921393
def parse_includes_to_nested_keys(includes)
@@ -1398,25 +1399,16 @@ def parse_includes_to_nested_keys(includes)
13981399
parts = include_path.to_s.split('.')
13991400
next if parts.empty?
14001401

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)
1402+
# Process each level of nesting
1403+
# For path "a.b.c.d": a gets b, b gets c, c gets d
1404+
parts.each_with_index do |part, index|
1405+
field_name = part.to_sym
1406+
nested_map[field_name] ||= []
1407+
1408+
# If there's a next part, add it to this field's nested keys
1409+
if index < parts.length - 1
1410+
next_field = parts[index + 1].to_sym
1411+
nested_map[field_name] << next_field unless nested_map[field_name].include?(next_field)
14201412
end
14211413
end
14221414
end

0 commit comments

Comments
 (0)