Module: Familia::Horreum::Persistence

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

Overview

Serialization - Instance-level methods for object persistence and retrieval Handles conversion between Ruby objects and Valkey hash storage

Instance Method Summary collapse

Instance Method Details

#apply_fields(**fields) ⇒ self

Updates the object by applying multiple field values.

Sets multiple attributes on the object instance using their corresponding setter methods. Only fields that have defined setter methods will be updated.

Examples:

Update multiple fields on an object

user.apply_fields(name: "John", email: "john@example.com", age: 30)
# => #<User:0x007f8a1c8b0a28 @name="John", @email="john@example.com", @age=30>

Parameters:

  • fields (Hash)

    Hash of field names (as keys) and their values to apply to the object instance.

Returns:

  • (self)

    Returns the updated object instance for method chaining.



249
250
251
252
253
254
255
# File 'lib/familia/horreum/persistence.rb', line 249

def apply_fields(**fields)
  fields.each do |field, value|
    # Apply the field value if the setter method exists
    send("#{field}=", value) if respond_to?("#{field}=")
  end
  self
end

#batch_update(**kwargs) ⇒ MultiResult

Updates multiple fields atomically in a Database transaction.

Examples:

Update multiple fields without affecting expiration

.batch_update(viewed: 1, updated: Time.now.to_i, update_expiration: false)

Update fields with expiration refresh

user.batch_update(name: "John", email: "john@example.com")

Parameters:

  • kwargs (Hash)

    Field names and values to update. Special key :update_expiration controls whether to update key expiration (default: true)

Returns:



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/familia/horreum/persistence.rb', line 213

def batch_update(**kwargs)
  update_expiration = kwargs.delete(:update_expiration) { true }
  fields = kwargs

  Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?

  transaction_result = transaction do |conn|
    fields.each do |field, value|
      prepared_value = serialize_value(value)
      conn.hset dbkey, field, prepared_value
      # Update instance variable to keep object in sync
      send("#{field}=", value) if respond_to?("#{field}=")
    end
  end

  # Update expiration if requested and supported
  self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)

  # Return the MultiResult directly (transaction already returns MultiResult)
  transaction_result
end

#clear_fields!void

Note:

This operation does not persist the changes to the DB. Call save after clear_fields! if you want to persist the cleared state.

This method returns an undefined value.

Clears all fields by setting them to nil.

Resets all object fields to nil values, effectively clearing the object's state. This operation affects all fields defined on the object's class, setting each one to nil through their corresponding setter methods.

Examples:

Clear all fields on an object

user.name = "John"
user.email = "john@example.com"
user.clear_fields!
# => user.name and user.email are now nil


320
321
322
# File 'lib/familia/horreum/persistence.rb', line 320

def clear_fields!
  self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
end

#commit_fields(update_expiration: true) ⇒ Object

Note:

The expiration update is only performed for classes that have the expiration feature enabled. For others, it's a no-op.

Note:

This method performs debug logging of the object's class, dbkey, and current state before committing to the DB.

Commits object fields to the DB storage.

Persists the current state of all object fields to the DB using HMSET. Optionally updates the key's expiration time if the feature is enabled for the object's class.

Examples:

Basic usage

user.name = "John"
user.email = "john@example.com"
result = user.commit_fields

Without updating expiration

result = user.commit_fields(update_expiration: false)

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the expiration time of the Valkey key. Defaults to true.

Returns:

  • (Object)

    The result of the HMSET operation from the DB.



187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/familia/horreum/persistence.rb', line 187

def commit_fields(update_expiration: true)
  prepared_value = to_h_for_storage
  Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"

  result = hmset(prepared_value)

  # Only classes that have the expiration ferature enabled will
  # actually set an expiration time on their keys. Otherwise
  # this will be a no-op that simply logs the attempt.
  update_expiration(default_expiration: nil) if update_expiration

  result
end

#destroy!void

Note:

This method provides high-level object lifecycle management. It operates at the object level for ORM-style operations, while delete! operates directly on database keys. Use destroy! when removing complete objects from the system.

Note:

When debugging is enabled, this method will trace the deletion operation for diagnostic purposes.

This method returns an undefined value.

Permanently removes this object and its related fields from the DB.

Deletes the object's database key and all associated data. This operation is irreversible and will permanently destroy all stored information for this object instance and the additional list, set, hash, string etc fields defined for this class.

Examples:

Remove a user object from storage

user = User.new(id: 123)
user.destroy!
# Object is now permanently removed from the DB

See Also:



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/familia/horreum/persistence.rb', line 281

def destroy!
  Familia.trace :DESTROY, dbkey, uri

  # Execute all deletion operations within a transaction
  transaction do |conn|
    # Delete the main object key
    conn.del(dbkey)

    # Delete all related fields if present
    if self.class.relations?
      Familia.trace :DELETE_RELATED_FIELDS!, nil,
                    "#{self.class} has relations: #{self.class.related_fields.keys}"

      self.class.related_fields.each do |name, _definition|
        obj = send(name)
        Familia.trace :DELETE_RELATED_FIELD, name, "Deleting related field #{name} (#{obj.dbkey})"
        conn.del(obj.dbkey)
      end
    end
  end
end

#refreshself

Refreshes object state from the DB and returns self for method chaining.

Loads the current state of the object from the DB storage, updating all field values to match their persisted state. This method provides a chainable interface to the refresh! operation.

Examples:

Refresh and chain operations

user.refresh.save
user.refresh.apply_fields(status: 'active')

Returns:

  • (self)

    The refreshed object instance, enabling method chaining

Raises:

See Also:



376
377
378
379
# File 'lib/familia/horreum/persistence.rb', line 376

def refresh
  refresh!
  self
end

#refresh!void

Note:

This method discards any unsaved changes to the object. Use with caution when the object has been modified but not yet persisted.

Note:

Transient fields are reset to nil during refresh since they have no authoritative source in Valkey storage.

This method returns an undefined value.

Refreshes the object state from the DB storage.

Reloads all persistent field values from the DB, overwriting any unsaved changes in the current object instance. This operation synchronizes the object with its stored state in the database.

Examples:

Refresh object from the DB

user.name = "Changed Name"  # unsaved change
user.refresh!
# => user.name is now the value from the DB storage

Raises:



345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/familia/horreum/persistence.rb', line 345

def refresh!
  Familia.trace :REFRESH, nil, uri if Familia.debug?
  raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)

  fields = hgetall
  Familia.ld "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"

  # Reset transient fields to nil for semantic clarity and ORM consistency
  # Transient fields have no authoritative source, so they should return to
  # their uninitialized state during refresh operations
  reset_transient_fields!

  optimistic_refresh(**fields)
end

#save(update_expiration: true) ⇒ Boolean

Note:

When Familia.debug? is enabled, this method will trace the save operation for debugging purposes.

Persists the object to Valkey storage with automatic timestamping.

Saves the current object state to Valkey storage, automatically setting created and updated timestamps if the object supports them. The method commits all persistent fields and optionally updates the key's expiration.

Examples:

Save an object to Valkey

user = User.new(name: "John", email: "john@example.com")
user.save
# => true

Save without updating expiration

user.save(update_expiration: false)
# => true

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the key's expiration time after saving. Defaults to true.

Returns:

  • (Boolean)

    true if the save operation was successful, false otherwise.

See Also:



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/familia/horreum/persistence.rb', line 63

def save(update_expiration: true)
  Familia.trace :SAVE, nil, uri if Familia.debug?

  # No longer need to sync computed identifier with a cache field
  self.created ||= Familia.now.to_i if respond_to?(:created)
  self.updated = Familia.now.to_i if respond_to?(:updated)

  # Commit our tale to the Database chronicles
  # Wrap in transaction for atomicity between save and indexing
  ret = commit_fields(update_expiration: update_expiration)

  # Auto-index for class-level indexes after successful save
  # Use transaction to ensure atomicity with the save operation
  if ret
    transaction do |conn|
      auto_update_class_indexes
      # Add to class-level instances collection after successful save
      self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
    end
  end

  Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"

  # Did Database accept our offering?
  !ret.nil?
end

#save_if_not_exists(update_expiration: true) ⇒ Boolean

Note:

This method uses HSETNX to atomically check and set the identifier field, ensuring race-condition-free conditional creation.

Saves the object to Valkey storage only if it doesn't already exist.

Conditionally persists the object to Valkey storage by first checking if the identifier field already exists. If the object already exists in storage, raises an error. Otherwise, proceeds with a normal save operation including automatic timestamping.

This method provides atomic conditional creation to prevent duplicate objects from being saved when uniqueness is required based on the identifier field.

Check if save_if_not_exists is implemented correctly. It should:

Check if record exists If exists, raise Familia::RecordExistsError If not exists, save

Examples:

Save a new user only if it doesn't exist

user = User.new(id: 123, name: "John")
user.save_if_not_exists
# => true (saved successfully)

Attempting to save an existing object

existing_user = User.new(id: 123, name: "Jane")
existing_user.save_if_not_exists
# => raises Familia::RecordExistsError

Save without updating expiration

user.save_if_not_exists(update_expiration: false)
# => true

Parameters:

  • update_expiration (Boolean) (defaults to: true)

    Whether to update the key's expiration time after saving. Defaults to true.

Returns:

  • (Boolean)

    true if the save operation was successful

Raises:

See Also:



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
# File 'lib/familia/horreum/persistence.rb', line 132

def save_if_not_exists(update_expiration: true)
  identifier_field = self.class.identifier_field

  Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
  Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?

  success = dbclient.watch(dbkey) do
    if dbclient.exists(dbkey).positive?
      dbclient.unwatch
      raise Familia::RecordExistsError, dbkey
    end

    result = dbclient.multi do |multi|
      multi.hmset(dbkey, to_h_for_storage)
    end

    result.is_a?(Array) # transaction succeeded
  end

  # Auto-index for class-level indexes after successful save
  # Use transaction to ensure atomicity with the save operation
  if success
    transaction do |conn|
      auto_update_class_indexes
    end
  end

  success
end