Module: Familia::Horreum::DirtyTracking

Included in:
Familia::Horreum
Defined in:
lib/familia/horreum/dirty_tracking.rb

Overview

DirtyTracking - Tracks in-memory field changes since last save/refresh.

Provides a minimal ActiveModel::Dirty-inspired API for detecting which scalar fields have been modified. This is useful for:

  • Knowing whether a save is needed
  • Warning when collection writes happen with unsaved scalar changes
  • Inspecting what changed and the old/new values

Fields are marked dirty automatically by the setter defined in FieldType. Dirty state is cleared after save, commit_fields, and refresh operations.

Uses Concurrent::Map for thread-safe access to the dirty fields tracker without requiring explicit mutex locks. The map is eagerly initialized in Horreum#initialize and the allocate-based load paths so that no lazy ||= race exists under normal usage. The ||= fallbacks in each method are a safety net for subclasses that override initialize without calling super (a documented anti-pattern).

Examples:

user = User.new(name: "Alice")
user.dirty?            # => false (just initialized)
user.name = "Bob"
user.dirty?            # => true
user.dirty?(:name)     # => true
user.changed_fields    # => { name: ["Alice", "Bob"] }
user.save
user.dirty?            # => false

Instance Method Summary collapse

Instance Method Details

#changed_fieldsHash{Symbol => Array(Object, Object)}

Returns a hash of changed fields with [old_value, new_value] pairs.

The old value is captured at the time of the first change since the last clear. The new value is read from the current instance variable.

Returns:

  • (Hash{Symbol => Array(Object, Object)})


85
86
87
88
89
90
91
92
93
# File 'lib/familia/horreum/dirty_tracking.rb', line 85

def changed_fields
  @dirty_fields ||= Concurrent::Map.new
  result = {}
  @dirty_fields.each_pair do |field_name, old_value|
    current_value = instance_variable_get(:"@#{field_name}")
    result[field_name] = [old_value, current_value]
  end
  result
end

#clear_dirty!(*field_names) ⇒ void

This method returns an undefined value.

Clears dirty tracking state for all or specific fields.

Called automatically after save, commit_fields, and refresh. When field names are provided, only those fields are cleared, preserving dirty state for fields that were not persisted.

Parameters:

  • field_names (Array<Symbol, String>)

    optional field names to clear. When empty, clears all dirty state (blanket reset).



105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/familia/horreum/dirty_tracking.rb', line 105

def clear_dirty!(*field_names)
  @dirty_fields ||= Concurrent::Map.new
  if field_names.empty?
    @dirty_fields.clear
  else
    field_names.each { |f| @dirty_fields.delete(f.to_sym) }
  end
  # Reset the dedup window on every clear_dirty! call. After a real clear
  # the dirty set has changed, so previously-warned signatures are stale.
  # On a no-op clear (a field name that was never dirty) the window still
  # resets, which at worst re-warns one already-seen signature -- harmless,
  # and not worth a branch to avoid. See record_dirty_warning!.
  @warned_dirty_signatures&.clear
end

#dirty?(field = nil) ⇒ Boolean

Whether any fields (or a specific field) have unsaved changes.

Parameters:

  • field (Symbol, String, nil) (defaults to: nil)

    optional field to check

Returns:

  • (Boolean)


60
61
62
63
64
65
66
67
# File 'lib/familia/horreum/dirty_tracking.rb', line 60

def dirty?(field = nil)
  @dirty_fields ||= Concurrent::Map.new
  if field
    @dirty_fields.key?(field.to_sym)
  else
    !@dirty_fields.empty?
  end
end

#dirty_fieldsArray<Symbol>

Returns the set of field names that have been modified.

Returns:

  • (Array<Symbol>)

    field names with unsaved changes



73
74
75
76
# File 'lib/familia/horreum/dirty_tracking.rb', line 73

def dirty_fields
  @dirty_fields ||= Concurrent::Map.new
  @dirty_fields.keys
end

#mark_dirty!(field_name, old_value) ⇒ void

This method returns an undefined value.

Mark a field as dirty, recording its old value before the change.

Called by the field setter in FieldType#define_setter. Only records the original value on the first change (subsequent changes update the current value but preserve the original baseline).

Parameters:

  • field_name (Symbol)

    the field that changed

  • old_value (Object)

    the value before the change



48
49
50
51
52
53
# File 'lib/familia/horreum/dirty_tracking.rb', line 48

def mark_dirty!(field_name, old_value)
  # Safety net for subclasses that override initialize without calling super
  @dirty_fields ||= Concurrent::Map.new
  # Atomic: only stores old_value if field_sym is not already tracked.
  @dirty_fields.put_if_absent(field_name.to_sym, old_value)
end

#record_dirty_warning!(signature) ⇒ Boolean

Records that a dirty-write warning has been emitted for +signature+ within the current dirty window, returning whether this is the first time that signature has been seen.

A "dirty window" spans from the first +mark_dirty!+ to the next +clear_dirty!+ (called by save, commit_fields, batch_update, refresh). +Familia::DataType#warn_if_dirty!+ uses this to dedupe warnings in :once mode -- warning once per distinct set of unsaved fields rather than once per collection write.

Parameters:

  • signature (Object)

    a value-equal, hashable key for the dirty set (typically the sorted, frozen array of dirty field names)

Returns:

  • (Boolean)

    true if this signature had not yet warned this window



134
135
136
137
138
139
140
# File 'lib/familia/horreum/dirty_tracking.rb', line 134

def record_dirty_warning!(signature)
  # Safety net for subclasses that override initialize without calling super.
  @warned_dirty_signatures ||= Concurrent::Map.new
  # Atomic: put_if_absent returns nil when the key was absent (and was
  # just stored), or the existing value otherwise. nil => first time.
  @warned_dirty_signatures.put_if_absent(signature, true).nil?
end