Module: Parse::Core::Fetching

Included in:
Object
Defined in:
lib/parse/model/core/fetching.rb

Overview

Defines the record fetching interface for instances of Parse::Object.

Constant Summary collapse

NON_SERIALIZABLE_IVARS =

Non-serializable instance variables that should be excluded from Marshal.

  • @fetch_mutex: Mutex objects cannot be marshalled

  • @client: HTTP client objects contain non-serializable connections

[:@fetch_mutex, :@client].freeze

Instance Method Summary collapse

Instance Method Details

#autofetch!(key, source_info: nil) ⇒ Boolean

Autofetches the object based on a key that is not part Properties::BASE_KEYS. If the key is not a Parse standard key, and the current object is in a Pointer state or was selectively fetched, then fetch the data related to this record from the Parse data store. Uses a mutex for thread safety to prevent race conditions in multi-threaded contexts.

Parameters:

  • key (String)

    the name of the attribute being accessed.

  • source_info (Hash) (defaults to: nil)

    optional info about where this autofetch was triggered from (used for N+1 detection with belongs_to associations)

Returns:

  • (Boolean)


427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/parse/model/core/fetching.rb', line 427

def autofetch!(key, source_info: nil)
  key = key.to_sym

  # Autofetch if object is a pointer OR was selectively fetched
  # Skip if autofetch is disabled for this instance
  needs_fetch = pointer? || has_selective_keys?
  return unless needs_fetch &&
                !autofetch_disabled? &&
                key != :acl &&
                !Parse::Properties::BASE_KEYS.include?(key) &&
                respond_to?(:fetch)

  # Capture caller stack BEFORE mutex for better error tracebacks
  # Filter out internal parse-stack frames to show where user code accessed the field
  caller_stack = caller.reject { |frame| frame.include?("/lib/parse/") }

  # Use mutex for thread-safe check-and-fetch pattern
  fetch_mutex.synchronize do
    # Double-check inside mutex (another thread may have fetched)
    return if !pointer? && !has_selective_keys?

    is_pointer_fetch = pointer?

    # Track for N+1 detection if enabled
    if is_pointer_fetch && Parse.warn_on_n_plus_one
      # Check for source info in the registry (set by belongs_to getter)
      n_plus_one_source = Parse::NPlusOneDetector.lookup_source(self)
      source_class = source_info&.dig(:source_class) || n_plus_one_source&.dig(:source_class) || self.class.name
      association = source_info&.dig(:association) || n_plus_one_source&.dig(:association) || key
      Parse::NPlusOneDetector.track_autofetch(
        source_class: source_class,
        association: association,
        target_class: self.class.name,
        object_id: id,
      )
    end

    # If autofetch_raise_on_missing_keys is enabled, raise an error instead of fetching
    # This helps developers identify where they need to add keys to their queries
    if Parse.autofetch_raise_on_missing_keys
      error = Parse::AutofetchTriggeredError.new(self.class, id, key, is_pointer: is_pointer_fetch)
      error.set_backtrace(caller_stack)
      raise error
    end

    # Log info about autofetch being triggered (conditional on warn_on_query_issues)
    if Parse.warn_on_query_issues
      if is_pointer_fetch
        puts "[Parse::Autofetch] Fetching #{self.class}##{id} - pointer accessed field :#{key} (silence with Parse.warn_on_query_issues = false)"
      else
        puts "[Parse::Autofetch] Fetching #{self.class}##{id} - field :#{key} was not included in partial fetch (silence with Parse.warn_on_query_issues = false)"
      end
    end

    # Autofetch always preserves changes - it's an implicit background operation
    # that shouldn't discard user modifications
    send :fetch, keys: nil, includes: nil, preserve_changes: true
  end
end

#fetchself #fetch(return_object) ⇒ self, Hash #fetch(keys:, includes:, preserve_changes:) ⇒ self

Fetches the object from the Parse data store. Unlike fetchIfNeeded, this always fetches from the server and updates the local object with fresh data.

Examples:

Full fetch

post.fetch

Partial fetch with specific keys

post.fetch(keys: [:title, :content])

Partial fetch with nested fields (pointer auto-resolved)

post.fetch(keys: ["title", "author.name", "author.email"])

Preserve local changes during fetch

post.fetch(keys: [:title], preserve_changes: true)

Overloads:

  • #fetchself

    Full fetch - fetches all fields

    Returns:

    • (self)

      the current object with updated data

  • #fetch(return_object) ⇒ self, Hash
    Deprecated.

    Use fetch or fetch_json instead

    Legacy signature for backward compatibility.

    Parameters:

    • return_object (Boolean)

      if true returns self, if false returns raw JSON

    Returns:

    • (self, Hash)

      the object or raw JSON data

  • #fetch(keys:, includes:, preserve_changes:) ⇒ self

    Partial fetch - fetches only specified fields

    Parameters:

    • keys (Array<Symbol, String>, nil)

      optional list of fields to fetch (partial fetch). If provided, only these fields will be fetched and the object will be marked as partially fetched. Use dot notation for nested fields (e.g., “author.name”) - pointer auto-resolved.

    • includes (Array<String>, nil)

      optional list of pointer fields to resolve as FULL objects. Only needed when you want the complete nested object without field restrictions.

    • preserve_changes (Boolean)

      if true, re-apply local dirty values to fetched fields. By default (false), fetched fields accept server values.

    Returns:

    • (self)

      the current object with updated data.



302
303
304
305
306
307
308
309
310
# File 'lib/parse/model/core/fetching.rb', line 302

def fetch(return_object = nil, keys: nil, includes: nil, preserve_changes: false)
  # Handle legacy signature: fetch(true) or fetch(false)
  if return_object == false
    return fetch_json(keys: keys, includes: includes)
  end
  # For fetch(), fetch(true), or fetch(keys: ..., includes: ..., preserve_changes: ...)
  fetch!(keys: keys, includes: includes, preserve_changes: preserve_changes)
  self
end

#fetch!(keys: nil, includes: nil, preserve_changes: false, **opts) ⇒ self

Force fetches and updates the current object with the data contained in the Parse collection. The changes applied to the object are not dirty tracked. By default, bypasses cache reads but updates the cache with fresh data (write-only mode). This ensures you always get fresh data while keeping the cache updated for future reads.

Examples:

Full fetch (updates cache but doesn’t read from it)

post.fetch!

Fetch with full caching (read and write)

post.fetch!(cache: true)

Fetch completely bypassing cache

post.fetch!(cache: false)

Partial fetch with specific keys

post.fetch!(keys: [:title, :content])

Partial fetch with nested fields (pointer auto-resolved)

post.fetch!(keys: ["title", "author.name", "author.email"])

Full nested object (includes required for full resolution)

post.fetch!(keys: [:title, :author], includes: [:author])

Preserve local changes during fetch

post.fetch!(keys: [:title], preserve_changes: true)

Parameters:

  • keys (Array<Symbol, String>, nil) (defaults to: nil)

    optional list of fields to fetch (partial fetch). If provided, only these fields will be fetched and the object will be marked as partially fetched. Use dot notation for nested fields (e.g., “author.name”) - Parse automatically resolves the pointer.

  • includes (Array<String>, nil) (defaults to: nil)

    optional list of pointer fields to resolve as FULL objects. Only needed when you want the complete nested object without field restrictions.

  • preserve_changes (Boolean) (defaults to: false)

    if true, re-apply local dirty values to fetched fields. By default (false), fetched fields accept server values and local changes are discarded. Unfetched fields always preserve their dirty state regardless of this setting.

  • opts (Hash)

    a set of options to pass to the client request.

Options Hash (**opts):

  • :cache (Boolean, Symbol) — default: :write_only

    caching mode:

    • :write_only (default) - skip cache read, but update cache with fresh data

    • true - read from and write to cache

    • false - completely bypass cache (no read or write)

Returns:

  • (self)

    the current object, useful for chaining.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/parse/model/core/fetching.rb', line 78

def fetch!(keys: nil, includes: nil, preserve_changes: false, **opts)
  # Default to write-only cache mode - fetch fresh data but update cache
  # This can be disabled globally with Parse.cache_write_on_fetch = false
  unless opts.key?(:cache)
    opts[:cache] = Parse.cache_write_on_fetch ? :write_only : false
  end

  # Normalize keys and includes arrays once at the start for performance
  keys_array = keys.present? ? Array(keys) : nil
  includes_array = includes.present? ? Array(includes) : nil

  # Build formatted keys once (reused for query and tracking)
  formatted_keys = keys_array&.map { |k| Parse::Query.format_field(k) }

  # Validate keys against model fields if validation is enabled
  # Skip validation if warnings are disabled (nothing to report)
  if keys_array && Parse.validate_query_keys && Parse.warn_on_query_issues
    validate_fetch_keys(keys_array)
  end

  # Build query parameters for partial fetch
  query = {}
  query[:keys] = formatted_keys.join(",") if formatted_keys
  query[:include] = includes_array.map(&:to_s).join(",") if includes_array

  response = client.fetch_object(parse_class, id, query: query.presence, **opts)
  if response.error?
    puts "[Fetch Error] #{response.code}: #{response.error}"
    # Raise appropriate error based on response code
    case response.code
    when 101 # Object not found
      raise Parse::Error::ProtocolError, "Object not found"
    else
      raise Parse::Error::ProtocolError, response.error
    end
  end

  # Handle empty results gracefully - clear the object rather than error
  result = response.result
  if result.nil? || (result.is_a?(Array) && result.empty?)
    # Mark object as deleted and clear the ID
    @_deleted = true
    @id = nil
    clear_changes!
    return self
  end

  # Handle case where result is an Array (e.g., batch operations or certain API responses)
  # This is unexpected for single-object fetch but handled defensively
  if result.is_a?(Array)
    warn "[Parse::Fetch] Unexpected array response for fetch_object (id: #{id}). This may indicate an API issue."
    result = result.find { |r| r.is_a?(Hash) && (r["objectId"] == id || r["id"] == id) }
    if result.nil?
      warn "[Parse::Fetch] Object #{id} not found in array response - marking as deleted"
      @_deleted = true
      @id = nil
      clear_changes!
      return self
    end
  end

  # If we successfully fetched data, ensure the object is not marked as deleted
  @_deleted = false

  # Capture dirty fields and their local values BEFORE applying server data
  dirty_fields = {}
  if respond_to?(:changed)
    begin
      changed_attrs = changed
      if changed_attrs.respond_to?(:each)
        changed_attrs.each do |attr|
          # Only capture if object responds to the attribute getter
          if respond_to?(attr)
            begin
              dirty_fields[attr.to_sym] = send(attr)
            rescue NoMethodError => e
              # Skip this attribute if its getter raises NoMethodError
              warn "[Parse::Fetch] Skipping dirty field :#{attr}: #{e.message}"
            end
          end
        end
      end
    rescue NoMethodError => e
      # Handle ActiveModel 8.x compatibility issues where `changed` method itself fails
      # due to unexpected state (e.g., after transaction rollback)
      warn "[Parse::Fetch] Warning: changed tracking unavailable: #{e.message}"
    end
  end

  # Determine if this is a partial fetch
  is_partial_fetch = keys_array.present?

  if is_partial_fetch
    # Build the new fetched keys list (top-level keys only, without nested paths)
    # Reuse formatted_keys instead of calling format_field again
    new_keys = formatted_keys.map { |k| k.split(".").first.to_sym }
    new_keys << :id unless new_keys.include?(:id)
    new_keys << :objectId unless new_keys.include?(:objectId)
    new_keys.uniq!

    # If already selectively fetched, merge with existing keys
    if has_selective_keys?
      @_fetched_keys = (@_fetched_keys + new_keys).uniq
    else
      @_fetched_keys = new_keys
    end

    # Parse keys with dot notation into nested fetched keys and merge
    new_nested_keys = Parse::Query.parse_keys_to_nested_keys(keys_array)
    if new_nested_keys.present?
      if @_nested_fetched_keys.present?
        # Merge nested keys
        new_nested_keys.each do |field, nested|
          @_nested_fetched_keys[field] ||= []
          @_nested_fetched_keys[field] = (@_nested_fetched_keys[field] + nested).uniq
        end
      else
        @_nested_fetched_keys = new_nested_keys
      end
    end
  else
    # Full fetch - clear partial fetch tracking
    @_fetched_keys = nil
    @_nested_fetched_keys = nil
  end

  # Apply attributes from server (only keys in result get updated)
  apply_attributes!(result, dirty_track: false)

  begin
    clear_changes!
  rescue => e
    # Log the error for debugging purposes
    warn "[Parse::Fetch] Warning: clear_changes! failed: #{e.class}: #{e.message}"
    # If clear_changes! fails, manually reset change tracking
    @changed_attributes = {} if instance_variable_defined?(:@changed_attributes)
    @mutations_from_database = nil if instance_variable_defined?(:@mutations_from_database)
    @mutations_before_last_save = nil if instance_variable_defined?(:@mutations_before_last_save)
  end

  # Handle previously dirty fields based on preserve_changes setting
  dirty_fields.each do |attr, local_value|
    attr_sym = attr.to_sym

    # Skip base fields (id, objectId, created_at, updated_at) - they should always accept server values
    next if Parse::Properties::BASE_KEYS.include?(attr_sym)

    # Determine the remote field name for this attribute
    remote_field = self.field_map[attr_sym]&.to_s || attr.to_s

    # Check if this field was in the server response (i.e., was fetched)
    field_in_response = result.key?(remote_field) || result.key?(attr.to_s)

    if field_in_response
      # Field was fetched from server
      current_server_value = send(attr)

      if preserve_changes
        # Re-apply local value - ActiveModel will mark dirty if value differs
        setter = "#{attr}="
        send(setter, local_value) if respond_to?(setter)
      else
        # Default behavior: accept server value, warn if local value was different
        if current_server_value != local_value
          puts "[Parse::Fetch] Field :#{attr} had unsaved changes that were discarded (local: #{local_value.inspect}, server: #{current_server_value.inspect}). Use preserve_changes: true to keep local changes."
        end
        # Server value is already applied, nothing more to do
      end
    else
      # Field was NOT fetched - always preserve dirty state
      # Use will_change! to mark as dirty since clear_changes! cleared the flag
      will_change_method = "#{attr}_will_change!"
      send(will_change_method) if respond_to?(will_change_method)
    end
  end

  self
end

#fetch_cache!(keys: nil, includes: nil, preserve_changes: false, **opts) ⇒ self

Fetches the object with explicit caching enabled. This is a convenience method that calls fetch! with cache: true. Use this when you want to leverage cached responses for better performance.

Examples:

Fetch with caching

post.fetch_cache!

Partial fetch with caching

post.fetch_cache!(keys: [:title, :content])

Parameters:

  • keys (Array<Symbol, String>, nil) (defaults to: nil)

    optional list of fields to fetch (partial fetch).

  • includes (Array<String>, nil) (defaults to: nil)

    optional list of pointer fields to resolve.

  • preserve_changes (Boolean) (defaults to: false)

    if true, re-apply local dirty values to fetched fields.

  • opts (Hash)

    additional options to pass to the client request.

Returns:

  • (self)

    the current object, useful for chaining.

See Also:



270
271
272
# File 'lib/parse/model/core/fetching.rb', line 270

def fetch_cache!(keys: nil, includes: nil, preserve_changes: false, **opts)
  fetch!(keys: keys, includes: includes, preserve_changes: preserve_changes, cache: true, **opts)
end

#fetch_json(keys: nil, includes: nil) ⇒ Hash?

Returns raw JSON data from the server without updating the current object.

Parameters:

  • keys (Array<Symbol, String>, nil) (defaults to: nil)

    optional list of fields to fetch.

  • includes (Array<String>, nil) (defaults to: nil)

    optional list of pointer fields to expand.

Returns:

  • (Hash, nil)

    the raw JSON data or nil if error.



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/parse/model/core/fetching.rb', line 316

def fetch_json(keys: nil, includes: nil)
  query = {}
  if keys.present?
    keys_array = Array(keys).map { |k| Parse::Query.format_field(k) }
    query[:keys] = keys_array.join(",")
  end
  if includes.present?
    includes_array = Array(includes).map(&:to_s)
    query[:include] = includes_array.join(",")
  end

  response = client.fetch_object(parse_class, id, query: query.presence)
  return nil if response.error?
  response.result
end

#fetch_objectParse::Object

Fetches the Parse object from the data store and returns a Parse::Object instance. This is a convenience method that calls fetch.

Returns:

  • (Parse::Object)

    the fetched Parse::Object (self if already fetched).



335
336
337
# File 'lib/parse/model/core/fetching.rb', line 335

def fetch_object
  fetch
end

#prepare_for_dirty_tracking!(key) ⇒ void

This method returns an undefined value.

Prepares object for dirty tracking by fetching if needed. Must be called BEFORE will_change! to prevent autofetch from wiping dirty state.

When will_change! captures the old value by calling the getter, it may trigger autofetch if the object is a pointer. That autofetch calls clear_changes! which wipes the dirty tracking state will_change! is trying to set up.

By fetching first, the object is no longer a pointer, so will_change! can proceed without triggering another fetch.

For selective fetch objects, this also marks the field as fetched to prevent autofetch during will_change!‘s getter call.

Parameters:

  • key (Symbol)

    the name of the attribute being set



502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/parse/model/core/fetching.rb', line 502

def prepare_for_dirty_tracking!(key)
  # Fetch before will_change! to prevent clear_changes! interference
  if pointer? && !autofetch_disabled?
    autofetch!(key)
  end

  # Mark selective fetch fields as fetched to prevent autofetch during will_change!
  if has_selective_keys? && !field_was_fetched?(key)
    @_fetched_keys ||= []
    @_fetched_keys << key unless @_fetched_keys.include?(key)
  end
end