Module: Hecks::Runtime::EraGuard::ShapeDiff

Included in:
Hecks::Runtime::EraGuard
Defined in:
lib/hecks/runtime/era_guard/shape_diff.rb

Overview

The pure half: computing an aggregate's structural signature and diffing two eras' shapes. No file I/O — everything here answers from the IR it is handed, which is why the Postgres mint path can call it too.

Instance Method Summary collapse

Instance Method Details

#attribute_signature(aggregate, type_name, seen = []) ⇒ Object

A plain type name for a primitive; [type_name, member_signatures] for a value object or entity, walked recursively — so two attributes with the same declared type name but a different internal shape are never mistaken for unchanged.



19
20
21
22
23
24
25
26
27
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 19

def attribute_signature(aggregate, type_name, seen = [])
  container = nested_type(aggregate, type_name)
  return type_name if container.nil? || seen.include?(type_name)

  members = container.attributes.map { |member|
    [member.name, attribute_signature(aggregate, member.type, seen + [type_name])]
  }.sort_by(&:first)
  [type_name, members]
end

#diff_type(path, held_type, current_type, held_aggregate, aggregate, lineage, seen = []) ⇒ Object

held_type/current_type are type NAMES, resolved against each side's OWN value_object AND entity declarations — neither is ever nested in the DSL, only in the type graph, so both are always looked up flat off their respective aggregate. A list_of entity is only reached here to DETECT a member vanish-or-retype; there is no per-element translation machinery yet (move/convert/drop only reach into a single nested hash, not each element of an array) — the only way to satisfy a refusal on an entity path today is a top-level drop of the whole list attribute, which explains? already recognizes as covering everything nested under it. Blunt, but loud beats silent.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 104

def diff_type(path, held_type, current_type, held_aggregate, aggregate, lineage, seen = [])
  if held_type != current_type
    # A declared retype says the two TYPE names mean the same shape
    # — accept the pair, but still recurse into the members so a
    # member drift hiding beneath the rename is caught by name.
    return [path] unless lineage&.retype?(held_type, current_type)
  end
  return [] if seen.include?(current_type)

  held_container = nested_type(held_aggregate, held_type)
  current_container = nested_type(aggregate, current_type)
  return [] unless held_container && current_container

  held_container.attributes.flat_map do |held_member|
    current_member = current_container.attribute(held_member.name)
    next ["#{path}.#{held_member.name}"] unless current_member

    diff_type("#{path}.#{held_member.name}", held_member.type, current_member.type, held_aggregate, aggregate, lineage,
              seen + [current_type])
  end
end

#nested_type(aggregate, type_name) ⇒ Object



29
30
31
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 29

def nested_type(aggregate, type_name)
  aggregate.value_object(type_name) || aggregate.entities.find { |entity| entity.name == type_name }
end

#possibly_absent?(aggregate, attribute) ⇒ Boolean

THE FOUR-ROW TABLE, as a predicate over the fourth row only — the other three (default:, list_of, a fully-defaulted value object) all fill an existing record for free and never reach here.

Returns:

  • (Boolean)


61
62
63
64
65
66
67
68
69
70
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 61

def possibly_absent?(aggregate, attribute)
  return false if attribute.optional?
  return false unless attribute.default.nil?
  return false if attribute.list?

  value_object = aggregate.value_object(attribute.type)
  return false if value_object && value_object.attributes.all? { |field| !field.default.nil? }

  true
end

#shape(aggregate) ⇒ Object



9
10
11
12
13
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 9

def shape(aggregate)
  aggregate.attributes.map { |attribute|
    [attribute.name, attribute_signature(aggregate, attribute.type)]
  }.sort_by(&:first)
end

#uncovered_attributes(aggregate, held_aggregate, lineage) ⇒ Object

Paths the translation needs to explain: attributes that vanished by name, attributes that kept their name but changed type (a convert is what lets that be declared at all), and — recursing into a same-named, same-typed value object — its OWN members vanishing or changing type one level down, reported as a dotted path ("price.currency"). A pure addition, at any depth, never needs covering; only vanish-or-retype does, matching the top-level rule at every depth.



80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 80

def uncovered_attributes(aggregate, held_aggregate, lineage)
  paths = held_aggregate.attributes.flat_map do |held_attribute|
    current_attribute = aggregate.attribute(held_attribute.name)
    next [held_attribute.name.to_s] unless current_attribute

    diff_type(held_attribute.name.to_s, held_attribute.type, current_attribute.type, held_aggregate, aggregate, lineage)
  end

  return paths unless lineage

  paths.reject { |path| lineage.explains?(path) }
end

#unsafe_additions(aggregate, held_aggregate, lineage) ⇒ Object

Attributes present now that the held shape never had — the addition side of drift, which uncovered_attributes above never looks at, since it only walks the HELD shape (vanish/retype). Most additions are free (ADR 0025, "Added attributes and absence"): a default:, a list_of (frozen []), or a value object whose fields all default fill an existing record automatically via Instance.hydrate_with_defaults. Only the fourth case — a non-optional attribute with no way to fill itself — can leave an existing record with the field genuinely absent, and that is what this reports: unfilled by a declared translation's own backfill, OR by a move/convert that lands an OLD field inside this brand-new attribute (Lineage#fills? — the destination- side question, never explains?'s source-side one, since a rename from Crate to Bin can introduce a top-level attribute name that never existed to have "vanished").



48
49
50
51
52
53
54
55
56
# File 'lib/hecks/runtime/era_guard/shape_diff.rb', line 48

def unsafe_additions(aggregate, held_aggregate, lineage)
  held_names = held_aggregate.attributes.map(&:name)

  aggregate.attributes
           .reject { |attribute| held_names.include?(attribute.name) }
           .select { |attribute| possibly_absent?(aggregate, attribute) }
           .reject { |attribute| lineage&.fills?(attribute.name.to_s) }
           .map(&:name)
end