Module: Hecks::Runtime::CommandRules::References

Included in:
Hecks::Runtime::CommandRules
Defined in:
lib/hecks/runtime/command_rules/references.rb

Overview

A reference must point at something that EXISTS.

reference_to Customer is the one guarantee an aggregate reference is for, and it was declared 14 times across banking and enforced nowhere : an Account could belong to a customer who was never registered, and every gate stayed green because no corpus step ever passed a dangling reference.

Instance Method Summary collapse

Instance Method Details

#dereference(domain, owner, source, depth: DEREFERENCE_DEPTH) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/hecks/runtime/command_rules/references.rb', line 159

def dereference(domain, owner, source, depth: DEREFERENCE_DEPTH)
  return {} if depth <= 0 || owner.nil?

  owner.attributes.each_with_object({}) do |attribute, hydrated|
    next unless attribute.reference?

    id = source[attribute.name]
    next if id.nil?

    target = referenced_aggregate(attribute)
    next unless target

    record = @registry.repository(domain, target).find(id.to_s)
    next unless record

    name = attribute.name.to_s.sub(/_id\z/, "").to_sym
    hydrated[name] = record.state.merge(dereference(domain, target, record.state, depth: depth - 1))
  end
end

#reference_key(value) ⇒ Object

value is the referenced record's own id, EXACTLY as Identity.of would build it for that record — a bare scalar for a single-field identity (the overwhelming common case; Banking's own plain reference_to Customer holds one already, so Value. materialize_unwrapped is a no-op passthrough here), or a Naming.identity-joined string for a compound one (belongs_to Translation, as: :translation_ref — Translation's own identified_by :domain, :from, :to, THREE fields). Before this, plain value.to_s on that compound case's own coerced Value hit Ruby's default Object#to_s (a raw, run-to-run-random memory address) instead of joining the record's real id — found live via bin/fuzz on the self-hosted "translation" domain (replay_is_deterministic), the SAME class of gap Identity.from already had for a compound identified_by's own bare (undotted) attribute paths. materialize_unwrapped recurses a multi- attribute value object to a plain Hash keyed by attribute name, in declaration order — Naming.identity on .values reproduces the identical join Identity.of itself would produce for the SAME fields.



124
125
126
127
128
129
# File 'lib/hecks/runtime/command_rules/references.rb', line 124

def reference_key(value)
  unwrapped = Value.materialize_unwrapped(value)
  return Naming.identity(unwrapped.values).to_s if unwrapped.is_a?(Hash)

  unwrapped.to_s
end

#referenced_aggregate(attribute) ⇒ Object

The reference RESOLVES itself — through the chapter's own IR, so the bluebook's declared heads are the index. This used to regex the target's name out of "Reference" and then search registry.bluebook(domain).aggregates for it — and later reached the target through Ruby's constant tree, a class thrown away for its .ir the moment it was found.



87
88
89
# File 'lib/hecks/runtime/command_rules/references.rb', line 87

def referenced_aggregate(attribute)
  attribute.type.resolve
end

#resolve_references(domain, command, args) ⇒ Object

Resolved here rather than in coercion because coercion is pure — it holds no repository. A reference INTO ANOTHER DOMAIN is left alone : a cross-domain target may legitimately not be loaded, which is the same reading across policies already get.

Shared by CommandInterpreter and EntityInterpreter — an entity command can declare a reference-typed attribute the same way an aggregate command can, even though nothing in the real corpus does yet.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/hecks/runtime/command_rules/references.rb', line 24

def resolve_references(domain, command, args)
  command.attributes.each do |attribute|
    next unless attribute.reference?
    next unless args.key?(attribute.name)

    held = args[attribute.name]
    next if held.nil?

    target = referenced_aggregate(attribute)
    next unless target

    validate_reference_values(domain, target, held, list: attribute.list?)
  end
end

#resolve_state_references(domain, construct, state) ⇒ Object

Structural references are checked again against the settled state. This is what makes a has_many declared on an aggregate honest even when a command supplies its list through an ordinary typed argument.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/hecks/runtime/command_rules/references.rb', line 42

def resolve_state_references(domain, construct, state)
  construct.attributes.each do |attribute|
    next unless attribute.reference?

    held = state[attribute.name]
    validate_relationship_cardinality(construct, attribute, held)
    next if held.nil?

    target = referenced_aggregate(attribute)
    next unless target

    validate_reference_values(domain, target, held, list: attribute.list?)
  end

  Array(construct.entities).each do |entity|
    field = construct.attribute(Naming.snake(entity.hecks_name).to_sym) ||
            construct.attributes.find { |attribute| attribute.type.to_s == entity.hecks_name.to_s }
    next unless field

    Array(state[field.name]).each { |row| resolve_state_references(domain, entity, row) }
  end
end

#validate_reference_values(domain, target, held, list:) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/hecks/runtime/command_rules/references.rb', line 91

def validate_reference_values(domain, target, held, list:)
  values = list ? Array(held) : [held]
  values.each do |value|
    key = reference_key(value)
    next if key.empty?
    next if @registry.repository(domain, target).find(key)

    raise NotFound,
          RefusalWording.render("NotFound", "reference_target_missing",
                                target: target.name, heads: target.identity_heads.join(", "),
                                key: key.inspect)
  end
end

#validate_relationship_cardinality(construct, attribute, held) ⇒ Object

has_one and belongs_to mean exactly one target unless the declaration explicitly says optional. State validation owns this boundary because a command may leave an aggregate field untouched; checking only command arguments would let a required relationship be persisted as nil. has_many admits zero members, so its empty list is already a valid cardinality and needs no presence refusal.

Raises:



71
72
73
74
75
76
77
78
79
# File 'lib/hecks/runtime/command_rules/references.rb', line 71

def validate_relationship_cardinality(construct, attribute, held)
  return if attribute.relationship.nil? || attribute.list?
  return unless held.nil? && !attribute.optional?

  raise TypeMismatch,
        "#{construct.hecks_name}.#{attribute.name} is a required " \
        "#{attribute.relationship} relationship — expected one " \
        "#{attribute.type.target_name} identity, got nil"
end