Module: Familia::Horreum::Serialization

Included in:
Core
Defined in:
lib/familia/horreum/core/serialization.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.



232
233
234
235
236
237
238
# File 'lib/familia/horreum/core/serialization.rb', line 232

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:



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/familia/horreum/core/serialization.rb', line 196

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


303
304
305
# File 'lib/familia/horreum/core/serialization.rb', line 303

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.



170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/familia/horreum/core/serialization.rb', line 170

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

#deserialize_value(val, symbolize: true) ⇒ Object

Converts a Database string value back to its original Ruby type

This method attempts to deserialize JSON strings back to their original Hash or Array types. Simple string values are returned as-is.

Parameters:

  • val (String)

    The string value from Database to deserialize

  • symbolize (Boolean) (defaults to: true)

    Whether to symbolize hash keys (default: true for compatibility)

Returns:

  • (Object)

    The deserialized value (Hash, Array, or original string)



511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/familia/horreum/core/serialization.rb', line 511

def deserialize_value(val, symbolize: true)
  return val if val.nil? || val == ''

  # Try to parse as JSON first for complex types
  begin
    parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
    # Only return parsed value if it's a complex type (Hash/Array)
    # Simple values should remain as strings
    return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
  rescue Familia::SerializerError
    # Not valid JSON, return as-is
  end

  val
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:



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/familia/horreum/core/serialization.rb', line 264

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:



359
360
361
362
# File 'lib/familia/horreum/core/serialization.rb', line 359

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:



328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/familia/horreum/core/serialization.rb', line 328

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
# File 'lib/familia/horreum/core/serialization.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
  #
  ret = commit_fields(update_expiration: update_expiration)

  # Add to class-level instances collection after successful save
  self.class.instances.add(identifier, Familia.now) if ret && self.class.respond_to?(:instances)

  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:



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/familia/horreum/core/serialization.rb', line 125

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?

  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
end

#serialize_value(val) ⇒ String?

Note:

This method integrates with Familia's type system and supports custom serialization methods when available on the object

Serializes a Ruby object for Valkey storage.

Converts Ruby objects into the DB-compatible string representations using the Familia distinguisher for type coercion. Falls back to JSON serialization for complex types (Hash, Array) when the primary distinguisher returns nil.

The serialization process:

  1. Attempts conversion using Familia.distinguisher with relaxed type checking
  2. For Hash/Array types that return nil, tries custom dump_method or Familia::JsonSerializer.dump
  3. Logs warnings when serialization fails completely

Examples:

Serializing different data types

serialize_value("hello")        # => "hello"
serialize_value(42)             # => "42"
serialize_value({name: "John"}) # => '{"name":"John"}'
serialize_value([1, 2, 3])      # => "[1,2,3]"

Parameters:

  • val (Object)

    The Ruby object to serialize for Valkey storage

Returns:

  • (String, nil)

    The serialized value ready for Valkey storage, or nil if serialization failed

See Also:

  • The primary serialization mechanism


484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/familia/horreum/core/serialization.rb', line 484

def serialize_value(val)
  # Security: Handle ConcealedString safely - extract encrypted data for storage
  return val.encrypted_value if val.respond_to?(:encrypted_value)

  prepared = Familia.distinguisher(val, strict_values: false)

  # If the distinguisher returns nil, try using the dump_method but only
  # use JSON serialization for complex types that need it.
  if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
    prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
  end

  # If both the distinguisher and dump_method return nil, log an error
  Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?

  prepared
end

#to_aArray

Note:

Values are serialized using the same process as other persistence methods to maintain data consistency across operations.

Converts the object's persistent fields to an array.

Serializes all persistent field values in field definition order, preparing them for Valkey storage. Each value is processed through the serialization pipeline to ensure Valkey compatibility.

Examples:

Converting an object to array format

user = User.new(name: "John", email: "john@example.com", age: 30)
user.to_a
# => ["John", "john@example.com", "30"]

Returns:

  • (Array)

    Array of serialized field values in field order



442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/familia/horreum/core/serialization.rb', line 442

def to_a
  self.class.persistent_fields.filter_map do |field|
    field_type = self.class.field_types[field]

    # Security: Skip non-loggable fields (e.g., encrypted fields)
    next unless field_type.loggable

    method_name = field_type.method_name
    val = send(method_name)
    prepared = serialize_value(val)
    Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
    prepared
  end
end

#to_hHash

Note:

Only loggable fields are included for security

Note:

Only fields with non-nil values are included

Converts the object's persistent fields to a hash for external use.

Serializes persistent field values for external consumption (APIs, logs), excluding non-loggable fields like encrypted fields for security. Only non-nil values are included in the resulting hash.

Examples:

Converting an object to hash format for API response

user = User.new(name: "John", email: "john@example.com", age: 30)
user.to_h
# => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
# encrypted fields are excluded for security

Returns:

  • (Hash)

    Hash with field names as keys and serialized values safe for external exposure



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/familia/horreum/core/serialization.rb', line 382

def to_h
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
    field_type = self.class.field_types[field]

    # Security: Skip non-loggable fields (e.g., encrypted fields)
    next unless field_type.loggable

    method_name = field_type.method_name
    val = send(method_name)
    prepared = serialize_value(val)
    Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"

    # Only include non-nil values in the hash for Valkey
    # Use string key for database compatibility
    hsh[field.to_s] = prepared unless prepared.nil?
  end
end

#to_h_for_storageHash

Note:

Includes ALL persistent fields, including encrypted fields

Note:

Only fields with non-nil values are included for storage efficiency

Converts the object's persistent fields to a hash for database storage.

Serializes ALL persistent field values for database storage, including encrypted fields. This is used internally by commit_fields and other persistence operations.

Returns:

  • (Hash)

    Hash with field names as keys and serialized values ready for database storage



412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/familia/horreum/core/serialization.rb', line 412

def to_h_for_storage
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
    field_type = self.class.field_types[field]
    method_name = field_type.method_name
    val = send(method_name)
    prepared = serialize_value(val)
    Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"

    # Only include non-nil values in the hash for Valkey
    # Use string key for database compatibility
    hsh[field.to_s] = prepared unless prepared.nil?
  end
end