Module: Familia::Horreum::DefinitionMethods

Includes:
RelatedFieldsManagement, Settings
Defined in:
lib/familia/horreum/definition.rb

Overview

DefinitionMethods - Class-level DSL methods for defining Horreum model structure

This module is extended into classes that include Familia::Horreum, providing class methods for defining model structure and configuration (e.g., Customer.field :name, Customer.identifier_field :custid).

Key features:

  • Defines DSL methods for field definitions (field, identifier_field)
  • Includes RelatedFieldsManagement for DataType field DSL (list, set, zset, etc.)
  • Provides class-level configuration (prefix, suffix, logical_database)
  • Manages field metadata and inheritance

Instance Attribute Summary

Attributes included from Settings

#current_key_version, #default_expiration, #delim, #encryption_hkdf_salt, #encryption_hkdf_salt_history, #encryption_keys, #encryption_personalization, #encryption_personalization_history, #raise_on_unsaved_parent_write, #schema_path, #schema_validator, #schemas, #strict_write_order, #transaction_mode

Class Method Summary collapse

Instance Method Summary collapse

Methods included from RelatedFieldsManagement

#attach_class_related_field, #attach_instance_related_field, #configure_related_field

Methods included from Settings

#configure, #default_suffix, #pipelined_mode, #pipelined_mode=

Class Method Details

.extended(base) ⇒ Object

Per-class locks and cache must exist before any thread can race on them, so they are created when the module is extended (Horreum's +inherited+ hook), not lazily on first use.



338
339
340
341
342
343
# File 'lib/familia/horreum/definition.rb', line 338

def self.extended(base)
  base.instance_variable_set(:@related_fields_mutex,
                             Familia::ThreadSafety::InstrumentedMutex.new('related_fields'))
  base.instance_variable_set(:@class_related_field_build_lock, ::Monitor.new)
  base.instance_variable_set(:@class_related_field_cache, {})
end

Instance Method Details

#add_feature_options(feature_name, **options) ⇒ Hash

Note:

This method only sets defaults for options that don't already exist, using the ||= operator to prevent overwrites.

Add feature options for a specific feature

This method provides a clean way for features to set their default options without worrying about initialization state. Similar to register_field_type for field types.

Feature options are stored at the class level using instance variables, ensuring complete isolation between different Familia::Horreum subclasses. Each class maintains its own @feature_options hash.

Examples:

Per-class storage behavior

class ModelA < Familia::Horreum
  # This stores options in ModelA's @feature_options
  add_feature_options(:my_feature, key: 'value_a')
end

class ModelB < Familia::Horreum
  # This stores options in ModelB's @feature_options (separate from ModelA)
  add_feature_options(:my_feature, key: 'value_b')
end

Parameters:

  • feature_name (Symbol)

    The feature name

  • options (Hash)

    The options to add/merge

Returns:

  • (Hash)

    The updated options for the feature



491
492
493
494
495
496
497
498
499
500
501
# File 'lib/familia/horreum/definition.rb', line 491

def add_feature_options(feature_name, **options)
  @feature_options ||= {}
  @feature_options[feature_name.to_sym] ||= {}

  # Only set defaults for options that don't already exist
  options.each do |key, value|
    @feature_options[feature_name.to_sym][key] ||= value
  end

  @feature_options[feature_name.to_sym]
end

Serializes the DataType constructions that must happen exactly once: class-level collections (materialize_class_related_field) and instance-level fields built after the instance's initial snapshot (Horreum#materialize_related_field, for a field declared late). A reentrant ::Monitor rather than a Mutex: a custom type's +init+ may read a sibling collection of the same class from inside the build, which re-enters this lock on the same thread. One per class, not per field or instance: a per-name table would need its own guarded creation, and serializing these one-time builds is cheap. The ordinary per-instance build in initialize_relatives does not take it.

Lock order is build lock, then related_fields_mutex. Nothing takes them the other way round: the registry paths (attach_*, configure_related_field, the freeze in initialize_relatives) hold the mutex alone and never construct under it.

Not ::Monitor's namesake Familia::ThreadSafety::Monitor, which is the contention reporter.



331
332
333
# File 'lib/familia/horreum/definition.rb', line 331

def class_related_field_build_lock
  @class_related_field_build_lock
end

Built class-level DataTypes, keyed by field name. A dedicated Hash rather than @ on the class: an unrelated class instance variable that happens to share a field's name must not be mistaken for the materialized collection. Per class; never copied by +inherited+ (a subclass builds its own, keyed under itself).



292
293
294
# File 'lib/familia/horreum/definition.rb', line 292

def class_related_field_cache
  @class_related_field_cache
end


282
283
284
285
# File 'lib/familia/horreum/definition.rb', line 282

def class_related_fields
  @class_related_fields ||= {}
  @class_related_fields
end

Class-level counterpart to +related_fields_snapshot+. Same mutex, same writer (attach_class_related_field), same hazard when iterated live.



366
367
368
# File 'lib/familia/horreum/definition.rb', line 366

def class_related_fields_snapshot
  related_fields_mutex.synchronize { class_related_fields.dup }
end

#dirty_write_warnings(mode = nil) ⇒ Symbol

Sets or retrieves how collection writes on this class's DataTypes react when the parent instance has unsaved scalar field changes.

Mirrors +Familia.strict_write_order+ but is scoped to a single Horreum subclass, so seed scripts, bulk importers, and known-safe call sites can opt down without touching the global setting.

Resolution order (highest precedence first):

  1. Active +atomic_write+ block -- suppresses everything.
  2. Class-level +:off+ -- suppresses both warnings AND raises, overriding +strict_write_order+ and +raise_on_unsaved_parent_write+. "Off means off."
  3. Raise gates: +Familia.strict_write_order = true+, class +:strict+, or a new/unsaved parent when +raise_on_unsaved_parent_write+ is true.
  4. Otherwise warn per the resolved mode: this class-level setting (inherited through the subclass chain), else +Familia.dirty_write_warnings+ (:once when unset).
  • :strict - raise Familia::Problem (overrides global strict_write_order=false)
  • :warn - warn on every collection write (legacy behavior)
  • :once - warn once per distinct dirty-field signature per window [default]
  • :off - suppress entirely for this class

Examples:

Silence a seed subclass

class SeedPlan < Billing::Plan
  dirty_write_warnings :off
end

Parameters:

  • mode (Symbol, nil) (defaults to: nil)

    one of :strict, :warn, :once, :off, or nil to read

Returns:

  • (Symbol)

    the resolved mode



255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/familia/horreum/definition.rb', line 255

def dirty_write_warnings(mode = nil)
  unless mode.nil?
    valid = %i[strict warn once off]
    unless valid.include?(mode)
      raise ArgumentError, "dirty_write_warnings must be one of #{valid.inspect}, got #{mode.inspect}"
    end
    return @dirty_write_warnings = mode
  end

  @dirty_write_warnings ||
    (superclass.respond_to?(:dirty_write_warnings) ? superclass.dirty_write_warnings : nil) ||
    Familia.dirty_write_warnings
end

#dirty_write_warnings=(mode) ⇒ Object



269
270
271
# File 'lib/familia/horreum/definition.rb', line 269

def dirty_write_warnings=(mode)
  dirty_write_warnings(mode)
end

#feature_options(feature_name = nil) ⇒ Hash

Retrieves feature options for the current class.

Feature options are stored per-class in instance variables, ensuring complete isolation between different Familia::Horreum subclasses. Each class maintains its own @feature_options hash that does not interfere with other classes' configurations.

Examples:

Getting options for a specific feature

class MyModel < Familia::Horreum
  feature :object_identifier, generator: :uuid_v4
end

MyModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
MyModel.feature_options                     #=> {object_identifier: {generator: :uuid_v4}}

Per-class isolation

class UserModel < Familia::Horreum
  feature :object_identifier, generator: :uuid_v4
end

class SessionModel < Familia::Horreum
  feature :object_identifier, generator: :hex
end

UserModel.feature_options(:object_identifier)    #=> {generator: :uuid_v4}
SessionModel.feature_options(:object_identifier) #=> {generator: :hex}

Parameters:

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

    the name of the feature to get options for. If nil, returns the entire feature options hash for this class.

Returns:

  • (Hash)

    the feature options hash, either for a specific feature or all features



456
457
458
459
460
461
# File 'lib/familia/horreum/definition.rb', line 456

def feature_options(feature_name = nil)
  @feature_options ||= {}
  return @feature_options if feature_name.nil?

  @feature_options[feature_name.to_sym] || {}
end

#field(name, as: name, fast_method: :"#{name}!", on_conflict: :raise) ⇒ Object

Defines a field for the class and creates accessor methods.

This method defines a new field for the class, creating getter and setter instance methods similar to attr_accessor. It also generates a fast writer method for immediate persistence to the database.

Parameters:

  • name (Symbol, String)

    the name of the field to define. If a method with the same name already exists, an error is raised.

  • as (Symbol, String, false, nil) (defaults to: name)

    as the name to use for the accessor method (defaults to name). If false or nil, no accessor methods are created.

  • fast_method (Symbol, false, nil) (defaults to: :"#{name}!")

    the name to use for the fast writer method (defaults to :"#{name}!"). If false or nil, no fast writer method is created.

  • on_conflict (Symbol) (defaults to: :raise)

    conflict resolution strategy when method already exists:

    • :raise - raise error if method exists (default)
    • :skip - skip definition if method exists
    • :warn - warn but proceed (may overwrite)
    • :ignore - proceed silently (may overwrite)


183
184
185
186
# File 'lib/familia/horreum/definition.rb', line 183

def field(name, as: name, fast_method: :"#{name}!", on_conflict: :raise)
  field_type = FieldType.new(name, as: as, fast_method: fast_method, on_conflict: on_conflict)
  register_field_type(field_type)
end

#field_group(name) { ... } ⇒ Array<Symbol>

Defines a field group to organize related fields.

Field groups provide a way to categorize and query fields by purpose or feature. When a block is provided, fields defined within the block are automatically added to the group. Without a block, an empty group is initialized.

Examples:

Manual field grouping

class User < Familia::Horreum
  field_group :personal_info do
    field :name
    field :email
  end
end

User.personal_info  # => [:name, :email]

Initialize empty group

class User < Familia::Horreum
  field_group :placeholder
end

User.placeholder  # => []

Parameters:

  • name (Symbol, String)

    the name of the field group

Yields:

  • optional block for defining fields within the group

Returns:

  • (Array<Symbol>)

    the array of field names in the group

Raises:



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/familia/horreum/definition.rb', line 88

def field_group(name, &block)

  # Prevent nested field groups
  if @current_field_group
    raise Familia::Problem,
      "Cannot define field group :#{name} while :#{@current_field_group} is being defined. " \
      "Nested field groups are not supported."
  end

  # Initialize group
  field_groups[name.to_sym] ||= []

  if block_given?
    @current_field_group = name.to_sym
    begin
      instance_eval(&block)
    ensure
      @current_field_group = nil
    end
  else
    Familia.debug "[field_group] Created field group :#{name} but no block given"
  end

  field_groups[name.to_sym]
end

#field_groupsArray<Symbol>

Returns the list of all field group names defined for the class.

Examples:

class User < Familia::Horreum
  field_group :personal_info do
    field :name
  end
  field_group :metadata do
    field :created_at
  end
end

User.field_groups  # => [
  :personal_info => [...],
  :metadata => [..]
]

Returns:

  • (Array<Symbol>)

    array of field group names



133
134
135
136
137
138
# File 'lib/familia/horreum/definition.rb', line 133

def field_groups
  @field_groups_mutex ||= Familia::ThreadSafety::InstrumentedMutex.new('field_groups')
  @field_groups || @field_groups_mutex.synchronize do
    @field_groups ||= {}
  end
end

#field_method_mapObject

Returns a hash mapping field names to method names for backward compatibility



383
384
385
# File 'lib/familia/horreum/definition.rb', line 383

def field_method_map
  field_types.transform_values(&:method_name)
end

#field_typesObject

Storage for field type instances



375
376
377
378
379
380
# File 'lib/familia/horreum/definition.rb', line 375

def field_types
  @field_types_mutex ||= Familia::ThreadSafety::InstrumentedMutex.new('field_types')
  @field_types || @field_types_mutex.synchronize do
    @field_types ||= {}
  end
end

#fieldsObject

Returns the list of field names defined for the class in the order that they were defined. i.e. field :a; field :b; fields => [:a, :b].



275
276
277
278
279
280
# File 'lib/familia/horreum/definition.rb', line 275

def fields
  @fields_mutex ||= Familia::ThreadSafety::InstrumentedMutex.new('fields')
  @fields || @fields_mutex.synchronize do
    @fields ||= []
  end
end

#identifier_field(val = nil) ⇒ Object

Sets or retrieves the unique identifier field for the class.

This method defines or returns the field or method that contains the unique identifier used to generate the dbkey for the object. If a value is provided, it sets the identifier field; otherwise, it returns the current identifier field.

Parameters:

  • val (Object) (defaults to: nil)

    the field name or method to set as the identifier field (optional).

Returns:

  • (Object)

    the current identifier field.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/familia/horreum/definition.rb', line 149

def identifier_field(val = nil)
  if val
    # Validate identifier field definition at class definition time
    case val
    when Symbol, String, Proc
      @identifier_field = val
    else
      raise Problem, <<~ERROR
        Invalid identifier field definition: #{val.inspect}.
        Use a field name (Symbol/String) or Proc.
      ERROR
    end
  end
  @identifier_field
end

#logical_database(num = nil) ⇒ Object



219
220
221
222
223
# File 'lib/familia/horreum/definition.rb', line 219

def logical_database(num = nil)
  Familia.trace :LOGICAL_DATABASE_DEF, "instvar:#{@logical_database}", num if Familia.debug?
  @logical_database = num unless num.nil?
  @logical_database || parent&.logical_database
end

#persistent_fieldsObject

Get fields for serialization (excludes transients)



388
389
390
391
392
# File 'lib/familia/horreum/definition.rb', line 388

def persistent_fields
  fields.select do |field|
    field_types[field]&.persistent?
  end
end

#prefix(val = nil) ⇒ String, Symbol

Sets or retrieves the prefix for generating Valkey/Redis keys.

The exception is only raised when both @prefix is nil/falsy AND name is nil, which typically occurs with anonymous classes that haven't had their prefix explicitly set.

Parameters:

  • a (String, Symbol, nil)

    the prefix to set (optional).

Returns:

  • (String, Symbol)

    the current prefix.



208
209
210
211
212
213
214
215
216
217
# File 'lib/familia/horreum/definition.rb', line 208

def prefix(val = nil)
  @prefix = val if val
  @prefix || begin
    if name.nil?
      raise Problem, 'Cannot generate prefix for anonymous class. ' \
                     'Use `prefix` method to set explicitly.'
    end
    config_name.to_sym
  end
end

#register_field_type(field_type) ⇒ Object

Register a field type instance with this class

This method installs the field type's methods and registers it for later reference. It maintains backward compatibility by creating FieldDefinition objects.

Parameters:

  • field_type (FieldType)

    The field type to register



409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/familia/horreum/definition.rb', line 409

def register_field_type(field_type)
  fields << field_type.name
  field_type.install(self)
  # Complete the registration after installation. If we do this beforehand
  # we can run into issues where it looks like it's already installed.
  field_types[field_type.name] = field_type

  # Add to current field group if one is active
  if @current_field_group
    @field_groups[@current_field_group] << field_type.name
  end

  # Freeze the field_type to ensure immutability (maintains Data class heritage)
  field_type.freeze
end


345
346
347
348
# File 'lib/familia/horreum/definition.rb', line 345

def related_fields
  @related_fields ||= {}
  @related_fields
end

Guards the related-field registries: declaration and re-declaration (attach_*_related_field), reconfiguration (configure_related_field) and the freeze that closes the window (initialize_relatives and materialize_class_related_field). One per class, like fields_mutex, but created eagerly in +extended+ rather than with ||=: two threads taking the lock for the first time would otherwise each allocate a mutex and exclude nothing.

Backed by a non-reentrant Mutex. The lifecycle only holds it around registry reads, replacements and the opts freeze; DataType construction (which runs user-overridable setters and +init+) happens outside it so a custom type may touch other collections.



308
309
310
# File 'lib/familia/horreum/definition.rb', line 308

def related_fields_mutex
  @related_fields_mutex
end

Returns a copy of the instance-level related-field registry, taken under related_fields_mutex.

attach_instance_related_field adds keys under that mutex; a cascade loop that iterates the live Hash instead would, if a new field were declared mid-iteration (application autoloading finishing a participates_in while an early request saves or expires a record), make MRI raise "can't add a new key into hash during iteration" in the declaring thread. Iterating this copy closes that window and releases the lock before the Redis calls in the cascade run.



360
361
362
# File 'lib/familia/horreum/definition.rb', line 360

def related_fields_snapshot
  related_fields_mutex.synchronize { related_fields.dup }
end

#relations?Boolean

Returns:

  • (Boolean)


370
371
372
# File 'lib/familia/horreum/definition.rb', line 370

def relations?
  @has_related_fields ||= false
end

#suffix(val = nil, &blk) ⇒ String, Symbol

Sets or retrieves the suffix for generating Valkey/Redis keys.

Parameters:

  • a (String, Symbol, nil)

    the suffix to set (optional).

  • blk (Proc)

    a block that returns the suffix (optional).

Returns:

  • (String, Symbol)

    the current suffix or Familia.default_suffix if none is set.



194
195
196
197
# File 'lib/familia/horreum/definition.rb', line 194

def suffix(val = nil, &blk)
  @suffix = val || blk if val || !blk.nil?
  @suffix || Familia.default_suffix
end

#transient_fieldsObject

Get fields that are not persisted to the database (transients)



395
396
397
398
399
# File 'lib/familia/horreum/definition.rb', line 395

def transient_fields
  fields.select do |field|
    field_types[field]&.transient?
  end
end