Module: OpenLoam::Enrichers

Defined in:
lib/open_loam/enrichers.rb

Overview

Response enrichers: one module attaches a computed block onto ANOTHER module's entity in admin/API responses, with no foreign-key coupling.

# billing knows about equipment; equipment knows nothing about billing:
OpenLoam::Enrichers.register("Equipment", key: "outstanding_balance") do |equipment|
Invoice.where(equipment_id: equipment.id).sum(:balance)
end

OpenLoam::Enrichers.enrich(equipment)        # => { "outstanding_balance" => 1200 }
OpenLoam::Enrichers.enrich_many(equipments)  # => { id => { key => value }, ... }

Distinct from custom fields: a custom field is STORED on the record; an enricher is COMPUTED at read time by different (possibly cross-module) code.

BATCH to avoid N+1: pass batch: (an array -> { record.id => value }) and enrich_many resolves N records in one query instead of N. enrich reuses the batch path for a single record, so batched enrichers are cheap either way.

Enrichers run in the CURRENT tenant context (the record was loaded there), so a resolver querying tenant-scoped models can only ever see this tenant's data. A resolver that raises is ISOLATED — its key is omitted, the rest still resolve, and the response is never broken. (No timeout in the prototype — a pathologically slow enricher is a future concern.)

Defined Under Namespace

Classes: Enricher

Class Method Summary collapse

Class Method Details

.enrich(record) ⇒ Object



40
41
42
# File 'lib/open_loam/enrichers.rb', line 40

def enrich(record)
  enrich_many([record]).fetch(record.id, {})
end

.enrich_many(records) ⇒ Object

{ record.id => { key => value } } for a homogeneous array of records.

Raises:

  • (ArgumentError)


45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/open_loam/enrichers.rb', line 45

def enrich_many(records)
  records = Array(records)
  return {} if records.empty?

  types = records.map { |record| record.class.base_class }.uniq
  raise ArgumentError, "enrich_many expects one entity type, got #{types.map(&:name).join(', ')}" if types.size > 1

  by_id = records.each_with_object({}) { |record, hash| hash[record.id] = {} }

  for_type(types.first).each do |enricher|
    # Per-enricher isolation: a raise omits THIS key for all records; the
    # others still resolve.
    begin
      if enricher.batch_resolver
        values = enricher.batch_resolver.call(records)
        records.each { |record| by_id[record.id][enricher.key] = values[record.id] }
      else
        records.each { |record| by_id[record.id][enricher.key] = enricher.resolver.call(record) }
      end
    rescue StandardError => error
      warn_failure(enricher, error)
    end
  end

  by_id
end

.register(entity_type, key:, priority: 0, batch: nil, &resolver) ⇒ Object

Register an enricher for records of entity_type (the model's base-class name, e.g. "Equipment"). Provide a per-record block OR a batch: proc.

Raises:

  • (ArgumentError)


31
32
33
34
35
36
37
38
# File 'lib/open_loam/enrichers.rb', line 31

def register(entity_type, key:, priority: 0, batch: nil, &resolver)
  raise ArgumentError, "register needs a block or a batch: resolver" unless resolver || batch

  registry[entity_type.to_s] << Enricher.new(
    entity_type: entity_type.to_s, key: key.to_s, priority: priority,
    resolver: resolver, batch_resolver: batch
  )
end

.restore(snapshot) ⇒ Object



79
80
81
82
# File 'lib/open_loam/enrichers.rb', line 79

def restore(snapshot)
  @registry = Hash.new { |hash, key| hash[key] = [] }
  snapshot.each { |type, enrichers| @registry[type] = enrichers.dup }
end

.snapshot ⇒ Object

--- test support: the registry is process-global, so a test snapshots it in setup and restores in teardown (keeping the app's boot-registered enrichers intact, which clear! would not).



75
76
77
# File 'lib/open_loam/enrichers.rb', line 75

def snapshot
  registry.transform_values(&:dup)
end