Module: Hecks::Runtime::Value::Coercion
- Included in:
- Hecks::Runtime::Value
- Defined in:
- lib/hecks/runtime/value/coercion.rb
Overview
The class-side engine: how a raw argument or stored field becomes a
typed Value. Extended into Value, so every method here reads as
Value.for, Value.build, … — self is the Value class.
Constant Summary collapse
- SHAPES =
THE FOUR SHAPES AN ATTRIBUTE'S VALUE CAN TAKE — named here because
for_attributeimmediately below is the one place that actually branches on all four, and nowhere else in the language collects them into a single closed list.Attribute#list?/#optional?are real predicates on the IR node itself (bluebook/attribute.rb);:scalarand:compositeare not named predicates there — they fall out of whetheraggregate.value_object(attribute.type)resolves to something, read directly in thecoerced =line below — but the branch is exactly as real, so it gets a name here too rather than staying anonymous.A SECOND RUNTIME'S KERNEL PORTS THIS METHOD BY HAND (rust/src/ kernel/attribute_shapes/*.rs — one file per name in this array, generated into a Rust enum by bin/project_kernel_capabilities so every match over it is compiler-checked exhaustive). If a fifth branch is ever added to
for_attribute, add its name here in the same breath — this list is that port's only source of truth for "which shapes exist," and a shape missing from it is a shape the generated Rust enum, and therefore the kernel, can never learn about no matter how correct the Ruby below is. %i[scalar list optional composite].freeze
- NUMERIC =
A field declared Integer or Float must ARRIVE as one.
Without this a String sails into a numeric field and the failure surfaces later, inside a predicate, as
positive? expects a number, got "three"— an EvaluationError, which is NOT a domain refusal. So the runtime broke where the domain should have said no, and the run contract recorded the crash beside genuine refusals as though the domain had judged it.Checked BEFORE invariants, because an invariant reading a mistyped field is exactly the thing that used to explode.
{ "Integer" => Integer, "Float" => Numeric }.freeze
- COMPOSITE_SHAPES =
A field declared
String(or a boolean) must not arrive as a COMPOSITE — an Array or a Hash (or a nested Value) standing in for what has to be a leaf scalar.Deliberately laxer than
check_numeric_fieldsabove : it does not enforce the exact Ruby class, only that the shape isn't a collection.Judge#v— the language's own self-hosted grammar validation — hands a String-typed field (Normalise'sposition, aRuleText) a raw Integer walk-index on purpose, on every boot, and that has always been tolerated ; a full String-vs-Integer check here would refuse the runtime's own bootstrap. But no scalar field, of any declared type, can ever legitimately be handed an Array or a Hash — that shape is always wrong, and always was:InvalidValueGenerator# array_for_scalar's own corruption is deliberately built to be REFUSED (see that file's header), and until this check existed it sailed straight through for a String/boolean field the way it never could for an Integer/Float one (check_numeric_fieldsabove already catches an Array offered for those). Found live via bin/fuzz, seed 17 on the fixtures domain : an Array standing in for a single-field identity's declaredString,.to_s'd into a record id downstream. [Array, ::Hash].freeze
- NON_NUMERIC_SCALARS =
%w[String TrueClass FalseClass].freeze
Instance Method Summary collapse
- #build(value_object, fields, aggregate = nil) ⇒ Object
- #canonical_fields(fields) ⇒ Object
- #fields_for(value_object, name, value) ⇒ Object
-
#find_entity(construct, name) ⇒ Object
S17, ADR 0026 — SEARCHES THE WHOLE ENTITY TREE, not only the root's own direct children.
- #for(aggregate, name, value) ⇒ Object
-
#for_attribute(aggregate, attribute, value) ⇒ Object
The four
SHAPESabove, in the order this method actually checks them:optional/nil-passthrough first (a value that isn't there has no shape left to branch on),listsecond (a list of elements, hydrated as entities), then — insidecoerced =—composite(the type names a declared value object, rebuilt recursively viabuild) withscalaras what's left once neither of those applies (the raw value, passed through unchanged). - #from_identifier(aggregate, attribute, identifier) ⇒ Object
- #hydrate(aggregate, state) ⇒ Object
-
#hydrate_entity_list(aggregate, attribute, value) ⇒ Object
Frozen through: a list read back out of the store is an answer, not a handle on what is stored.
-
#known_by(attribute) ⇒ Object
"(Account is known by number)" — what to send instead.
-
#normalize_composite_fields(aggregate, value_object, fields) ⇒ Object
build's own recursive twin offor_attribute's single-level normalization — a value object's OWN composite-typed fields (Pizza.price_cents, aPrice) never otherwise pass back throughfields_for, so a bare scalar or partial Hash for one of THOSE sails past the outer VO's own shape check (Pizzaitself has two fields, so nothing unwraps there) and lands stored one field down exactly as handed in — found live: once the fuzzer actually generated the bare-scalar shapefields_forhas accepted at the TOP level since 86727afd, a nestedPricestored as a raw Integer broke every later dotted-path read (pizza.price_cents.cents) expecting one more level of Hash. -
#reference_identity(attribute, value) ⇒ Object
Retained relationships store canonical target identities, not Ruby Value wrappers.
- #reference_list(attribute, value) ⇒ Object
-
#refuse_object_reference(command, attribute, value) ⇒ Object
A REFERENCE IS AN ID, SO AN OBJECT IS NOT ONE.
- #scalar(value) ⇒ Object
-
#value_object_for(aggregate, type) ⇒ Object
Aggregate-local value objects remain authoritative, which permits intentional duplication.
Instance Method Details
#build(value_object, fields, aggregate = nil) ⇒ Object
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/hecks/runtime/value/coercion.rb', line 218 def build(value_object, fields, aggregate = nil) fields = value_object.attributes.each_with_object(fields.transform_keys(&:to_sym)) do |attribute, completed| completed[attribute.name] = attribute.default unless completed.key?(attribute.name) || attribute.default.nil? end fields = normalize_composite_fields(aggregate, value_object, fields) admit_member(value_object, fields) check_admitted(value_object, fields) check_numeric_fields(value_object, fields) check_scalar_shapes(value_object, fields) check_patterns(value_object, fields) value_object.invariants.each do |invariant| next if Bluebook::Expression::Evaluator.call(invariant.canonical, fields) raise InvariantViolation, RefusalWording.render("InvariantViolation", "value_object_invariant", name: value_object.hecks_name, description: invariant.description, offered: canonical_fields(fields)) end new(value_object, fields) end |
#canonical_fields(fields) ⇒ Object
406 407 408 |
# File 'lib/hecks/runtime/value/coercion.rb', line 406 def canonical_fields(fields) JSON.generate(fields.sort_by { |name, _| name.to_s }.to_h) end |
#fields_for(value_object, name, value) ⇒ Object
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/hecks/runtime/value/coercion.rb', line 141 def fields_for(value_object, name, value) return value.transform_keys(&:to_sym) if value.is_a?(Hash) # Mutations may legitimately carry a value object into a differently # named value-object slot with the same declared fields (for example, # PositiveMoney into an Account's Money balance). Rebuild the target # type from its state; callers at the public boundary still have to # supply an object rather than a scalar. return value.to_h if value.is_a?(self) # Vendored addition, not (yet) upstream hecks (migration # plan task 5): a bare scalar auto-wraps into a single-field # value object's sole attribute -- the SAME shape # #from_identifier already establishes for identity coercion # (`build(value_object, { fields.first.name => identifier }) if # fields.size == 1`), made consistent here for MUTATION # coercion too. Real, corpus-wide gap: a synthesised single- # field wrapper (Part 3a's bare-primitive auto-synthesis, the # norm for a VO-typed aggregate field) is exactly the shape # #rewrap_arithmetic_result hands back a raw scalar RESULT to # -- without this, every phantom-field increment/multiply on a # single-field-wrapped attribute refused with "pass its fields # as an object, not <scalar>" the instant it tried to re-wrap # its own correctly-computed result. Multi-field VOs still # refuse below, unchanged -- only the genuinely unambiguous # single-field case auto-wraps, matching from_identifier's own # precedent exactly. if value_object.attributes.size == 1 return { value_object.attributes.first.name => value } end raise TypeMismatch, RefusalWording.render("TypeMismatch", "value_object_shape", name: name, type: value_object.hecks_name, offered: Rendering.describe(value)) end |
#find_entity(construct, name) ⇒ Object
S17, ADR 0026 — SEARCHES THE WHOLE ENTITY TREE, not only the
root's own direct children. aggregate here is always the
ROOT aggregate — for_attribute's own aggregate argument is
never reassigned as hydration recurses into a nested element,
because coercion has to resolve value objects, and only the
root answers .value_object at all (Entity's own header
comment: an entity must NOT answer to it, or Value. for_attribute could no longer tell a piece from a head). So
a NESTED entity — Dispatch, inside Handler — is not a direct
child of the root the way Handler itself is, and a plain
aggregate.entities.find stops one level short of it.
258 259 260 261 262 263 264 265 266 |
# File 'lib/hecks/runtime/value/coercion.rb', line 258 def find_entity(construct, name) construct.entities.each do |candidate| return candidate if candidate.hecks_name == name found = find_entity(candidate, name) return found if found end nil end |
#for(aggregate, name, value) ⇒ Object
37 38 39 40 41 42 |
# File 'lib/hecks/runtime/value/coercion.rb', line 37 def for(aggregate, name, value) attribute = aggregate.attribute(name) return value unless attribute for_attribute(aggregate, attribute, value) end |
#for_attribute(aggregate, attribute, value) ⇒ Object
The four SHAPES above, in the order this method actually checks
them: optional/nil-passthrough first (a value that isn't there
has no shape left to branch on), list second (a list of
elements, hydrated as entities), then — inside coerced = —
composite (the type names a declared value object, rebuilt
recursively via build) with scalar as what's left once
neither of those applies (the raw value, passed through
unchanged).
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/hecks/runtime/value/coercion.rb', line 52 def for_attribute(aggregate, attribute, value) return value if attribute.nil? || value.nil? # :optional return reference_list(attribute, value) if attribute.list? && attribute.reference? return reference_identity(attribute, value) if attribute.reference? return hydrate_entity_list(aggregate, attribute, value) if attribute.list? # :list return value unless aggregate.respond_to?(:value_object) # THE SET THE ATTRIBUTE NAMES IS CHECKED WHERE THE ATTRIBUTE IS KNOWN. # `build` below sees only the value object, never which attribute asked # for it, so a command argument's `admits:` has to be read here — this # is the door every argument and every head field comes through. # # AFTER coercion, not before: a scalar arrives wrapped in whatever holder # its type names (`{value: "append"}` for an OpName), and checking the # raw payload would be checking the envelope. coerced = value_object_for(aggregate, attribute.type) .then do |value_object| if value.is_a?(self) && value.type_name == value_object&.hecks_name value elsif value_object build(value_object, fields_for(value_object, attribute.name, value), aggregate) else value end end admit_declared_set(aggregate, attribute, coerced) coerced end |
#from_identifier(aggregate, attribute, identifier) ⇒ Object
356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/hecks/runtime/value/coercion.rb', line 356 def from_identifier(aggregate, attribute, identifier) value_object = value_object_for(aggregate, attribute.type) return identifier unless value_object fields = value_object.attributes if fields.size == 1 field = fields.first return build(value_object, { field.name => coerce_identifier(field, identifier) }) end raise TypeMismatch, RefusalWording.render("TypeMismatch", "composite_identity", type: value_object.hecks_name) end |
#hydrate(aggregate, state) ⇒ Object
239 240 241 242 243 244 245 |
# File 'lib/hecks/runtime/value/coercion.rb', line 239 def hydrate(aggregate, state) state.each_with_object({}) do |(name, value), hydrated| key = name.to_sym attribute = aggregate.attribute(key) hydrated[key] = attribute ? for_attribute(aggregate, attribute, value) : value end end |
#hydrate_entity_list(aggregate, attribute, value) ⇒ Object
Frozen through: a list read back out of the store is an answer, not a handle on what is stored.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/hecks/runtime/value/coercion.rb', line 270 def hydrate_entity_list(aggregate, attribute, value) entity = find_entity(aggregate, attribute.type.to_s) return value unless entity Array(value).map do |element| next element unless element.is_a?(Hash) element.each_with_object({}) do |(name, field_value), hydrated| key = name.to_sym field = entity.attribute(key) hydrated[key] = field ? for_attribute(aggregate, field, field_value) : field_value end end .then { |hydrated| Freezer.deep(hydrated) } end |
#known_by(attribute) ⇒ Object
"(Account is known by number)" — what to send instead. No article, on purpose: "an Account" and "a Customer" differ by the target's first letter, and a refusal pinned byte-for-byte should not hinge on an article-choosing rule. Silent when the target is another chapter's, where this runtime cannot see what it is known by.
EVERY HEAD, because a caller has to pass every one. This read
identified_by, which is the SINGLE head and is nil the moment an
identity has two parts — so a composite target fell through the guard
and the refusal went silent exactly where it had the most to say. A
single-path target reads as it always did.
340 341 342 343 344 345 |
# File 'lib/hecks/runtime/value/coercion.rb', line 340 def known_by(attribute) heads = Array(attribute.type.resolve&.identity_heads) return "" if heads.empty? " (#{attribute.type.target_name} is known by #{heads.join(', ')})" end |
#normalize_composite_fields(aggregate, value_object, fields) ⇒ Object
build's own recursive twin of for_attribute's single-level
normalization — a value object's OWN composite-typed fields
(Pizza.price_cents, a Price) never otherwise pass back
through fields_for, so a bare scalar or partial Hash for one
of THOSE sails past the outer VO's own shape check (Pizza
itself has two fields, so nothing unwraps there) and lands
stored one field down exactly as handed in — found live: once
the fuzzer actually generated the bare-scalar shape
fields_for has accepted at the TOP level since 86727afd, a
nested Price stored as a raw Integer broke every later
dotted-path read (pizza.price_cents.cents) expecting one
more level of Hash.
Stays a plain Hash, never a nested Value — Value#with's own
header and materialize_unwrapped's comment already depend on
a value-object-typed field of ANOTHER value object staying a
plain Hash once stored, and this does not change that; it only
makes sure that Hash has the shape its own type declares.
aggregate is the one thing build didn't used to need — a
nested type can only be resolved through aggregate. value_object(name), so callers with no aggregate in reach
(Value#with, always re-setting an already-scalar arithmetic
field) simply skip this and keep their prior behavior.
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/hecks/runtime/value/coercion.rb', line 200 def normalize_composite_fields(aggregate, value_object, fields) return fields unless aggregate&.respond_to?(:value_object) value_object.attributes.each do |attribute| next if attribute.list? || !fields.key?(attribute.name) raw = fields[attribute.name] next if raw.nil? || raw.is_a?(self) nested = value_object_for(aggregate, attribute.type) next unless nested fields[attribute.name] = normalize_composite_fields(aggregate, nested, fields_for(nested, attribute.name, raw)) end fields end |
#reference_identity(attribute, value) ⇒ Object
Retained relationships store canonical target identities, not Ruby Value wrappers. Raw scalar IDs remain a compatibility input. A named identity VO omits its minted aggregate field at the command boundary; a bespoke compound VO may instead name the target heads directly. Neither form requires reverse-splitting a canonical ID.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/hecks/runtime/value/coercion.rb', line 105 def reference_identity(attribute, value) return value unless value.is_a?(self) || value.is_a?(Hash) target = attribute.type.resolve return value unless target materialized = materialize(value) paths = target.identity_paths return value if paths.empty? direct_head = if value.is_a?(self) && target.identity_heads.one? head = target.identity_heads.first target.attribute(head)&.type.to_s == value.type_name ? head.to_s : nil end parts = paths.map do |path| segments = path.to_s.split(".") segments.shift if direct_head && segments.first == direct_head segments.reduce(materialized) do |held, segment| held.is_a?(Hash) ? (held[segment.to_sym] || held[segment]) : nil end end return value if parts.any? { |part| part.nil? || (part.respond_to?(:empty?) && part.empty?) } Naming.identity(parts) end |
#reference_list(attribute, value) ⇒ Object
132 133 134 135 136 137 138 139 |
# File 'lib/hecks/runtime/value/coercion.rb', line 132 def reference_list(attribute, value) unless value.is_a?(Array) raise TypeMismatch, "#{attribute.name} is a has_many relationship — pass a list of identities" end Freezer.deep(value.dup) end |
#refuse_object_reference(command, attribute, value) ⇒ Object
A REFERENCE IS AN ID, SO AN OBJECT IS NOT ONE.
Nothing coerces a reference — for_attribute misses on
"Reference
This is that place. It sits at the payload gate rather than inside
coercion because the sentence names the COMMAND, and for_attribute
never learns which command it is serving.
An Array is deliberately not refused here. A reference is never a list today, and inventing a rule for a shape the language cannot declare is how decoration gets written.
317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/hecks/runtime/value/coercion.rb', line 317 def refuse_object_reference(command, attribute, value) return unless attribute.reference? offered = attribute.list? ? Array(value).find { |item| item.is_a?(Hash) || item.is_a?(self) } : value return unless offered.is_a?(Hash) || offered.is_a?(self) raise TypeMismatch, RefusalWording.render("TypeMismatch", "reference_as_object", command: command.hecks_name, attribute: attribute.name, known_by: known_by(attribute)) end |
#scalar(value) ⇒ Object
347 348 349 350 351 352 353 354 |
# File 'lib/hecks/runtime/value/coercion.rb', line 347 def scalar(value) return value unless value.is_a?(self) fields = value.to_h return fields.values.first if fields.size == 1 raise TypeMismatch, RefusalWording.render("TypeMismatch", "multi_field_scalar", type: value.type_name) end |
#value_object_for(aggregate, type) ⇒ Object
Aggregate-local value objects remain authoritative, which permits intentional duplication. An ordinary fact may also name an identity value object declared on another aggregate; that shape is borrowed only when every chapter declaration with the name agrees.
86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/hecks/runtime/value/coercion.rb', line 86 def value_object_for(aggregate, type) local = aggregate.value_object(type) return local if local chapter = aggregate.respond_to?(:hecks_owner) ? aggregate.hecks_owner : nil return nil unless chapter.respond_to?(:aggregates) matches = chapter.aggregates.filter_map { |candidate| candidate.value_object(type) } shapes = matches.group_by do |shape| shape.attributes.map { |field| [field.name, field.type.to_s, field.list?, field.optional?] } end shapes.size == 1 ? matches.first : nil end |