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
# 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
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