Module: Hecks::Runtime::EntityElement
- Defined in:
- lib/hecks/runtime/entity_element.rb
Overview
ONE ENTITY ELEMENT, LOCATED AND MUTATED — the walk-and-write half of
dispatching into a piece an aggregate holds, factored out of
EntityInterpreter so a SECOND caller (CommandInterpreter's own
delegate_to_entity step) can locate and mutate the SAME element the
same way, against an aggregate record it already holds in memory
rather than one freshly loaded from a repository. EntityInterpreter
keeps its own dispatch order and Context; this is the part underneath
that both now share, so it exists in exactly one place rather than two
that could only ever drift (the same reasoning Runtime::Identity's
own header gives for the join/dig/reading trio it centralizes).
rules (a CommandRules instance) is passed explicitly throughout
rather than closed over, since this module has no instance of its own
to hold one.
Class Method Summary collapse
-
.appended_to_element(aggregate, entity, element, mutation, args) ⇒ Object
MutationApplier#appended's own entity-scoped twin. -
.apply_to_element(rules, aggregate, entity, element, mutation, args) ⇒ Object
S17, ADR 0026 —
:append/:remove/:multiply/:clamp, an entity-scoped mirror ofCommandInterpreter::MutationApplier #apply's own four (that module's own header names each one's origin). -
.element_identity(entity, element) ⇒ Object
THE ELEMENT'S OWN IDENTITY, joined from its parts — the entity-level twin of
Identity.of, reading off the STORED ELEMENT (a Hash) rather than a dispatch payload. -
.element_of(root_aggregate, owner, entity, command_name, container, args, routed_identity = nil) ⇒ Object
ONE ELEMENT, MATCHED ON EVERY PART OF ITS IDENTITY — not just the first.
-
.locate_chain(root_aggregate, chain, instance, args, command_name, route = nil) ⇒ Object
ONE HOP PER CHAIN ENTRY.
-
.removed_from_element(rules, aggregate, entity, element, mutation, args) ⇒ Object
MutationApplier#removed's own entity-scoped twin — matches by VALUE EQUALITY, element-wise, the same "so a concurrent Add can never be lost" reasoning that method's own comment gives. -
.resolve_element_append_source(source, element, args) ⇒ Object
MutationApplier#resolve_append_source's own entity-scoped twin — a caller-supplied ARG first, falling back to the ELEMENT's own current field (never the parent instance's) when it isn't one. -
.rewrap_arithmetic_result(aggregate, attribute, current, result) ⇒ Object
MutationApplier#rewrap_arithmetic_result's own entity-scoped twin, byte-for-byte the same fix — see that method's own comment for the full "phantom-field asymmetric wrapping" story.
Class Method Details
.appended_to_element(aggregate, entity, element, mutation, args) ⇒ Object
MutationApplier#appended's own entity-scoped twin. VALUE-
OBJECT elements only — an entity's own list, appended to by an
entity-owned command, holds a value object (Member.pairs'
own Pair, Dispatch.with_spec's own Binding) the same way
every real corpus append does; entity-in-entity nesting (a
list of ANOTHER entity, owned by this one) is out of scope —
MutationApplier#entity_element's own fallback is deliberately
not mirrored here, since nothing in this language's own
EntityBuilder can declare a nested entity to need it (see
S17's own scoping note on why Dispatch flattens under
ProcessManager instead of nesting under Handler).
231 232 233 234 235 236 237 238 239 240 |
# File 'lib/hecks/runtime/entity_element.rb', line 231 def appended_to_element(aggregate, entity, element, mutation, args) fields = mutation.source.transform_values { |source| resolve_element_append_source(source, element, args) } element_type = entity.attribute(mutation.target)&.type value_object = aggregate.value_object(element_type) value_object&.attributes&.each do |attribute| fields[attribute.name] = Value.scalar(fields[attribute.name]) if fields[attribute.name].is_a?(Value) end appended = value_object ? Value.build(value_object, fields, aggregate) : fields Freezer.deep(Array(element[mutation.target]) + [appended]) end |
.apply_to_element(rules, aggregate, entity, element, mutation, args) ⇒ Object
S17, ADR 0026 — :append/:remove/:multiply/:clamp, an
entity-scoped mirror of CommandInterpreter::MutationApplier #apply's own four (that module's own header names each one's
origin). Missing until now — an entity-owned command declaring
sets :some_list, append: {...} matched no when here and
silently no-opped, the one place this language otherwise
refuses what it cannot check applying nothing instead. Member/
Dispatch (S17) are the first real callers: both need to
append a value-object-typed element (Pair/Binding) onto a
list attribute THEY OWN, once they become entities of
ValueObject/ProcessManager rather than separate aggregates.
:increment/:decrement/:multiply ALSO fixed here, found
while proving this method against a real fixture: they wrapped
amount unconditionally whenever attribute existed, the same
asymmetric-wrapping shape MutationApplier#rewrap_arithmetic_ result's own comment documents fixing at the aggregate level
(migration plan task 9) — a phantom-created VO-typed field's
current reads back a raw, unwrapped default, amount was
wrapped anyway, and the two sides of one arithmetic call
disagreed on Value-ness. Confirmed live, not theoretical: this
method's own fixture (TaggedList.Bump, a VO-typed count with
default: 0) raised exactly this TypeMismatch on its first
real run.
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/hecks/runtime/entity_element.rb', line 161 def apply_to_element(rules, aggregate, entity, element, mutation, args) case mutation.op when :set value = rules.resolve_source(mutation.source, args) attribute = entity.attribute(mutation.target) element[mutation.target] = attribute ? Value.for_attribute(aggregate, attribute, value) : value when :append element[mutation.target] = appended_to_element(aggregate, entity, element, mutation, args) when :remove element[mutation.target] = removed_from_element(rules, aggregate, entity, element, mutation, args) when :increment, :decrement attribute = entity.attribute(mutation.target) amount = rules.resolve_source(mutation.source, args) current = element[mutation.target] amount = Value.for_attribute(aggregate, attribute, amount) if attribute && current.is_a?(Value) result = rules.arithmetic(current, amount, mutation.target, rules.sign_of(mutation.op)) element[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result) when :multiply attribute = entity.attribute(mutation.target) amount = rules.resolve_source(mutation.source, args) current = element[mutation.target] amount = Value.for_attribute(aggregate, attribute, amount) if attribute && current.is_a?(Value) result = rules.multiply(current, amount, mutation.target) element[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result) when :clamp element[mutation.target] = rules.clamp(element[mutation.target], mutation.source, mutation.target) else # The aggregate-level twin's own backstop # (MutationApplier#apply), for the same reason: applying # nothing and refusing nothing would be the one silent # no-op in a language that otherwise refuses what it cannot # check. raise WiringError, "no entity mutation applier handles :#{mutation.op} — add one before declaring it" end end |
.element_identity(entity, element) ⇒ Object
THE ELEMENT'S OWN IDENTITY, joined from its parts — the entity-level
twin of Identity.of, reading off the STORED ELEMENT (a Hash) rather
than a dispatch payload. An id is a SCALAR, and the PATH is how it is
reached — never by opening a value object and taking whatever single
field is inside. That unwrapping is gone from the language: a piece
that does not name its fields is refused when the bluebook loads ("an
entity says what it is known by", "an identity part names something"),
so by the time a dispatch arrives here there is always a path to dig.
128 129 130 131 132 133 134 135 |
# File 'lib/hecks/runtime/entity_element.rb', line 128 def element_identity(entity, element) parts = entity.identity_paths.map do |path| head = path.to_s.split(".").first.to_sym Identity.scalar(path, element[head]) end Naming.identity(parts) end |
.element_of(root_aggregate, owner, entity, command_name, container, args, routed_identity = nil) ⇒ Object
ONE ELEMENT, MATCHED ON EVERY PART OF ITS IDENTITY — not just the first.
A piece's identity may be several paths, the same shape a head's can be,
so a dispatch that names the element has to supply every part and every
part has to agree with the stored one. routed_identity, when given,
matches by the element's own minted identity string directly instead
(element_identity, below) — the routing envelope already resolved
which element it means, so re-deriving wants from args would be
redundant at best and wrong if args no longer carries that identity
at all.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 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 113 114 115 116 117 118 |
# File 'lib/hecks/runtime/entity_element.rb', line 66 def element_of(root_aggregate, owner, entity, command_name, container, args, routed_identity = nil) entity_name = entity.hecks_name list_attr = owner.attributes.find { |a| a.list? && a.type.to_s == entity_name } || raise(UnknownVerb, RefusalWording.render("UnknownVerb", "entity_holds_no_list", aggregate: owner.hecks_name, entity: entity_name)) wants = unless routed_identity entity.identity_paths.map do |path| head = path.to_s.split(".").first.to_sym raw = args[head] || raise(NotFound, RefusalWording.render("NotFound", "entity_element_no_identity", command: command_name, entity: entity_name, identity: Identity.reading(entity))) [head, path, Value.for_attribute(root_aggregate, entity.attribute(head), raw)] end end original = Array(container[list_attr.name]) position = if routed_identity original.find_index { |element| element_identity(entity, element).to_s == routed_identity.to_s } else original.find_index { |el| wants.all? { |head, _path, want| el[head] == want } } end unless position raise NotFound, RefusalWording.render( "NotFound", "entity_element_missing", entity: entity_name, identity: Identity.reading(entity), wants: wants&.map { |_h, path, want| Identity.scalar(path, want) }&.join(", "), aggregate: owner.hecks_name, parent_id: container.respond_to?(:id) ? container.id.inspect : Rendering.describe(container) ) end # ONE LEVEL DEEPER THAN Instance#dup, for the same reason: a list # attribute holds Hashes, and `apply_to_element` mutates the found # one IN PLACE — the update mechanism for an entity, not a bug. But # in place means aliased with the adapter's own record until this # copies the array and the target element before handing either # back, and writes the fresh array into `container` so the copy is # what persists on success and NOTHING aliased survives a refusal. # `container[list_attr.name] = copied` reaches `instance` itself # when this is the FIRST hop, and reaches the (already copied) # PARENT element when it is a later one — either way it is the # SAME already-fresh object `locate_chain` is about to hand back # as `container` for the next hop, so nothing further has to # propagate a write back up the chain by hand. copied = original.dup element = copied[position].dup copied[position] = element container[list_attr.name] = copied element end |
.locate_chain(root_aggregate, chain, instance, args, command_name, route = nil) ⇒ Object
ONE HOP PER CHAIN ENTRY. container starts as instance (the root
aggregate record) and becomes each just-located element in turn —
Dispatch's own element is found INSIDE the Handler element
locate_chain located the step before, never inside instance
directly. owner is whichever construct's OWN attribute declares
the list being searched (Handler declares dispatches ; the root
aggregate declares handlers) — root_aggregate stays the ROOT
the whole way through instead, passed to element_of separately,
because coercion (Value.for_attribute) resolves value objects
against the root's own namespace only ; an entity must never
answer .value_object (Entity's own header comment) so handing
it an intermediate owner instead would break every VO-typed
identity field a nested entity declares.
route, if given, is the routing envelope's own entity chain
(ctx.route.entities, one entry per hop) — the routing/payload
split's own consequence: an entity's own identity may arrive via
to: rather than duplicated into args, so each hop is offered
its routed identity ahead of falling back to args.
46 47 48 49 50 51 52 53 54 55 |
# File 'lib/hecks/runtime/entity_element.rb', line 46 def locate_chain(root_aggregate, chain, instance, args, command_name, route = nil) container = instance owner = root_aggregate chain.each_with_index do |entity, index| container = element_of(root_aggregate, owner, entity, command_name, container, args, route&.entities&.fetch(index)) owner = entity end container end |
.removed_from_element(rules, aggregate, entity, element, mutation, args) ⇒ Object
MutationApplier#removed's own entity-scoped twin — matches by
VALUE EQUALITY, element-wise, the same "so a concurrent Add can
never be lost" reasoning that method's own comment gives.
245 246 247 248 249 250 |
# File 'lib/hecks/runtime/entity_element.rb', line 245 def removed_from_element(rules, aggregate, entity, element, mutation, args) value = rules.resolve_source(mutation.source, args) attribute = entity.attribute(mutation.target) value = Value.for_attribute(aggregate, attribute, value) if attribute Array(element[mutation.target]).reject { |candidate| candidate == value } end |
.resolve_element_append_source(source, element, args) ⇒ Object
MutationApplier#resolve_append_source's own entity-scoped
twin — a caller-supplied ARG first, falling back to the
ELEMENT's own current field (never the parent instance's) when
it isn't one.
213 214 215 216 217 218 |
# File 'lib/hecks/runtime/entity_element.rb', line 213 def resolve_element_append_source(source, element, args) return source unless source.is_a?(Symbol) return args[source] if args.key?(source) element[source] end |
.rewrap_arithmetic_result(aggregate, attribute, current, result) ⇒ Object
MutationApplier#rewrap_arithmetic_result's own entity-scoped
twin, byte-for-byte the same fix — see that method's own
comment for the full "phantom-field asymmetric wrapping" story.
A no-op whenever current was already a Value (the arithmetic
call already returned one) or the mutation targets no declared
attribute at all.
203 204 205 206 207 |
# File 'lib/hecks/runtime/entity_element.rb', line 203 def rewrap_arithmetic_result(aggregate, attribute, current, result) return result if current.is_a?(Value) || attribute.nil? || result.is_a?(Value) Value.for_attribute(aggregate, attribute, result) end |