Module: Familia::Horreum::RelatedFieldsManagement

Included in:
DefinitionMethods, ManagementMethods
Defined in:
lib/familia/horreum/related_fields.rb

Overview

RelatedFieldsManagement - Class-level methods for defining DataType relationships

This module uses metaprogramming to dynamically create field definition methods that generate both class-level and instance-level accessor methods for DataTypes (e.g., list, set, zset, hashkey, string).

When included in a class via ManagementMethods, it provides class methods like:

  • Customer.list :recent_orders # defines class method for class-level list
  • customer.recent_orders # creates instance method returning list instance

Key metaprogramming features:

  • Dynamically defines DSL methods for each Database type (e.g., set, list, hashkey)
  • Each DSL method creates corresponding instance/class accessor methods
  • Provides query methods for checking relation types

Usage: Include this module in classes that need DataType management Call setup_related_fields_definition_methods to initialize the feature

Defined Under Namespace

Modules: RelatedFieldsAccessors

Instance Method Summary collapse

Instance Method Details

Creates a class-level relation

The DataType is built lazily on first access rather than at declaration, so configure_related_field can still adjust the definition between the class body and first use. The first access freezes that definition's opts, closing the window for this field only (Klass.instances is touched on every save and must not close it for unrelated fields).

Because the build is deferred, options are validated here so a bad max_length: still fails at the declaration line, not on first access. Re-declaring a name whose collection has already been built raises RelatedFieldFrozenError: the cached DataType would keep serving the old options while the registry showed the new ones.

Raises:

  • (ArgumentError)


255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/familia/horreum/related_fields.rb', line 255

def attach_class_related_field(name, klass, opts)
  Familia.trace :attach_class_related_field, "#{name} #{klass}", opts if Familia.debug?
  raise ArgumentError, 'Name is blank (klass)' if name.to_s.empty?

  name = name.to_s.to_sym
  opts = opts.nil? ? {} : opts.dup
  opts[:parent] = self unless opts.key?(:parent)
  validate_related_field_opts!(opts, klass)

  # Accessors before the registry entry, for the same reason as
  # attach_instance_related_field: readers of a registry snapshot
  # (guard_atomic_write_database!, the class-level destroy!) must never
  # see a name whose accessor does not exist yet.
  define_singleton_method name do
    materialize_class_related_field(name)
  end

  define_singleton_method :"#{name}=" do |v|
    send(name).replace v
  end
  define_singleton_method :"#{name}?" do
    !send(name).empty?
  end

  # Check-and-replace under the lock materialize_class_related_field
  # freezes under; see attach_instance_related_field for the race.
  related_fields_mutex.synchronize do
    refuse_related_field_redeclaration!(class_related_fields[name], "#{self}.#{name}")
    class_related_fields[name] = RelatedFieldDefinition.new(name, klass, opts)
  end
end

Creates an instance-level relation

Options are validated here, at declaration, with the same checks the DataType constructor runs (see validate_related_field_opts!).

Re-declaring a name whose definition has already materialized (an instance of this class exists) raises RelatedFieldFrozenError: instances built before and after the re-declaration would otherwise disagree about the field's options. Re-declaring BEFORE first use replaces the definition, as it always has. The same name may also exist at class level (zset :instances alongside the automatic class_sorted_set :instances); configure_related_field takes a +scope:+ keyword to reach the class-level one.

Raises:

  • (ArgumentError)


178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/familia/horreum/related_fields.rb', line 178

def attach_instance_related_field(name, klass, opts)
  Familia.trace :attach_instance_related_field, name, klass, opts if Familia.debug?
  raise ArgumentError, "Name is blank (#{klass})" if name.to_s.empty?

  name = name.to_s.to_sym
  # Dup: the lifecycle freezes this Hash at first materialization and
  # must not freeze an object the caller still owns.
  opts = opts.nil? ? {} : opts.dup
  validate_related_field_opts!(opts, klass)

  # Accessors first, registry entry second. The cascades
  # (update_expiration, persist!, ttl_report, destroy!) snapshot the
  # registry and call +send(name)+ on the instance; a definition that
  # landed before define_method ran would let a cascade snapshotting
  # in between raise NoMethodError. The accessors depend only on the
  # name, so redefining them for a re-declaration the check below
  # refuses is harmless.
  #
  # Lazy-initializing accessor. Three paths:
  #
  # 1. @<name> is set: return it (every access after the first).
  # 2. Relatives never initialized (initialize overridden without
  #    super, or the load path): run initialize_relatives, which builds
  #    every definition in the registry, this one included.
  # 3. Relatives initialized but @<name> still nil: the field was
  #    declared after this instance took its snapshot (participates_in
  #    at load, or any late declaration). Materialize just this one
  #    from the current definition (see materialize_related_field);
  #    the instance is not recreated and the cascades that walk the
  #    current registry keep working on it.
  define_method name do
    ivar = :"@#{name}"
    value = instance_variable_get(ivar)
    return value unless value.nil?

    # Check singleton class to avoid polluting instance variables
    unless singleton_class.instance_variable_defined?(:@relatives_initialized)
      initialize_relatives
      value = instance_variable_get(ivar)
      return value unless value.nil?
    end

    materialize_related_field(name)
  end

  define_method :"#{name}=" do |val|
    send(name).replace val
  end
  define_method :"#{name}?" do
    !send(name).empty?
  end

  # Check-and-replace under the same lock initialize_relatives freezes
  # under. Unlocked, a re-declaration could pass the frozen? check,
  # then the first instance builds and freezes the OLD definition, then
  # the replacement lands: first instance on 10, registry (and every
  # later instance) on 20.
  related_fields_mutex.synchronize do
    refuse_related_field_redeclaration!(related_fields[name], "#{self}##{name}")
    related_fields[name] = RelatedFieldDefinition.new(name, klass, opts)
  end
end

Reconfigures a related field after the class body has run.

Lifecycle:

  1. Declare in the class body (sorted_set :events, max_length: 100).
  2. Reconfigure at boot, before any instance is created or the class-level collection is accessed.
  3. Frozen at first use: instance-level definitions freeze together at the end of the first initialize_relatives; each class-level definition freezes on its own first accessor call. A later call raises rather than leaving already-built DataTypes on old options.

New declarations on a materialized class remain allowed (participation relies on that); only reconfiguring or re-declaring a frozen definition is refused.

A name can be declared at both levels (zset :instances next to the automatic class_sorted_set :instances). Without +scope:+ the instance-level definition wins when both exist and the class-level one is used when only it exists; pass +scope: :class+ (or +scope: :instance+) to address one level explicitly, in which case only that registry is consulted.

Examples:

Raise a cap at boot from configuration

class Customer < Familia::Horreum
  sorted_set :events, max_length: 100
  class_sorted_set :registry
end
Customer.configure_related_field(:events, max_length: settings.events_cap)
Customer.configure_related_field(:registry, max_length: 500)

Same name at both levels: reach the class-level one

Customer.configure_related_field(:instances, scope: :class, max_length: 10_000)

Parameters:

  • name (Symbol, String)

    the field name as declared

  • scope (nil, :instance, :class) (defaults to: nil)

    which registry to address; nil (default) prefers instance-level, falling back to class-level

  • opts (Hash)

    options merged over the current definition's opts

Returns:

Raises:

  • (ArgumentError)

    if the class has no such field in the given scope, if scope is not nil/:instance/:class, or the merged options would be rejected by the DataType constructor (same error class and message the constructor raises)

  • (Familia::RelatedFieldFrozenError)

    if the definition has already been materialized



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/familia/horreum/related_fields.rb', line 331

def configure_related_field(name, scope: nil, **opts)
  name = name.to_s.to_sym

  related_fields_mutex.synchronize do
    registry = related_field_registry_for(name, scope)
    unless registry.key?(name)
      level = { instance: 'instance-level ', class: 'class-level ' }[scope]
      raise ArgumentError, "#{self} has no #{level}related field #{name.inspect}"
    end

    definition = registry[name]
    if definition.opts.frozen?
      scope = if registry.equal?(related_fields)
        "instance-level #{self}##{name}: instances already exist"
      else
        "class-level #{self}.#{name}: the collection was already built"
      end
      raise Familia::RelatedFieldFrozenError,
            "Cannot reconfigure #{scope}. configure_related_field must run before first use."
    end

    merged = definition.opts.merge(opts)
    validate_related_field_opts!(merged, definition.klass)

    registry[name] = definition.with(opts: merged)
  end
end