Module: Familia::Horreum::Serialization

Included in:
Core
Defined in:
lib/familia/horreum/core/serialization.rb

Overview

Serialization: Object persistence and retrieval from the DB 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.



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

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:

  • fields (Hash)

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

Returns:



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

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

  Familia.trace :BATCH_UPDATE, dbclient, fields.keys, caller(1..1) if Familia.debug?

  command_return_values = 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 same MultiResult format as other methods
  summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
  MultiResult.new(summary_boolean, command_return_values)
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


283
284
285
# File 'lib/familia/horreum/core/serialization.rb', line 283

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.



167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/familia/horreum/core/serialization.rb', line 167

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_keys (Boolean)

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

Returns:

  • (Object)

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



493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/familia/horreum/core/serialization.rb', line 493

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

  # Try to parse as JSON first for complex types
  begin
    parsed = JSON.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 JSON::ParserError
    # 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 from the DB storage.

Deletes the object’s Valkey key and all associated data. This operation is irreversible and will permanently destroy all stored information for this object instance.

Examples:

Remove a user object from storage

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

See Also:



261
262
263
264
# File 'lib/familia/horreum/core/serialization.rb', line 261

def destroy!
  Familia.trace :DESTROY, dbclient, uri, caller(1..1) if Familia.debug?
  delete!
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:



339
340
341
342
# File 'lib/familia/horreum/core/serialization.rb', line 339

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:



308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/familia/horreum/core/serialization.rb', line 308

def refresh!
  Familia.trace :REFRESH, dbclient, uri, caller(1..1) 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
# File 'lib/familia/horreum/core/serialization.rb', line 63

def save(update_expiration: true)
  Familia.trace :SAVE, dbclient, uri, caller(1..1) 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)

  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:



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

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, dbclient, uri, caller(1..1) 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 JSON.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


464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/familia/horreum/core/serialization.rb', line 464

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

  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) : JSON.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



422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/familia/horreum/core/serialization.rb', line 422

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



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/familia/horreum/core/serialization.rb', line 362

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



392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/familia/horreum/core/serialization.rb', line 392

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