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_attribute immediately 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); :scalar and :composite are not named predicates there — they fall out of whether aggregate.value_object(attribute.type) resolves to something, read directly in the coerced = 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_fields above : 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's position, a RuleText) 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_fields above 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 declared String, .to_s'd into a record id downstream.

[Array, ::Hash].freeze
NON_NUMERIC_SCALARS =
%w[String TrueClass FalseClass].freeze

Instance Method Summary collapse

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

Raises:



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

Raises:



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 ValueValue#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", which is no value object's name, and hands the argument straight through. That is why the wrapped form went in unnoticed for as long as it did: there was no place it could be refused, so whatever the first caller wrote became the shape.

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.

Raises:



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

Raises:



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