Class: Hecks::Bluebook::DSL::AggregateBuilder

Inherits:
Object
  • Object
show all
Includes:
AttributeCollector, IdentityDeclaration, RuleReference, WordGate
Defined in:
lib/hecks/bluebook/dsl/aggregate_builder.rb

Constant Summary collapse

GRAMMAR_CONTEXT =
"Aggregate"

Constants included from WordGate

WordGate::NOT_ADMITTED

Constants included from RuleReference

RuleReference::BOOTSTRAP_FALLBACK

Class Method Summary collapse

Instance Method Summary collapse

Methods included from RuleReference

build_rule, lookup, resolve_hash_chain, resolve_owner_keyed, resolve_sibling_scan, verify_resolves_via!

Methods included from IdentityDeclaration

#identified_by_impl

Methods included from AttributeCollector

#attribute_impl, #attributes, #closed_sets, #list_of_impl, #one_of_impl

Constructor Details

#initialize(name, chapter_named_givens: {}, chapter_pending_givens: [], chapter_entity_named_givens: {}, chapter_entity_pending_givens: []) ⇒ AggregateBuilder

Returns a new instance of AggregateBuilder.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 13

def initialize(name, chapter_named_givens: {}, chapter_pending_givens: [],
               chapter_entity_named_givens: {}, chapter_entity_pending_givens: [])
  @name          = name
  @value_objects = []
  @commands      = []
  @invariants    = []
  @named_givens  = {}
  @projected_fields = []
  @identity_paths = []
  @entities      = []
  @queries       = []
  @policies      = []
  @reference_targets = []
  # THE ROOT of the cross-entity given pool — see `#entity`'s own
  # comment. ONE hash for the whole aggregate, threaded unchanged
  # into every piece nested under it, however deep.
  @entity_named_givens = {}
  # ONE LEVEL WIDER STILL — the CHAPTER's own pool, threaded in
  # from `BluebookBuilder#aggregate`, shared with every OTHER
  # aggregate the same chapter builds. See `#given`'s own
  # comment for what this closes.
  @chapter_named_givens = chapter_named_givens
  # A CHAPTER MAY BE SPLIT ACROSS FILES — threaded in the SAME
  # way as `@chapter_named_givens`, one Array shared chapter-wide.
  # See `#pending_chapter_given`'s own comment for what queues
  # here and `BluebookBuilder#resolve_pending_chapter_givens!`
  # for where it drains.
  @chapter_pending_givens = chapter_pending_givens
  # ONE LEVEL WIDER STILL, PAST THE CHAPTER'S OWN AGGREGATE-LEVEL
  # POOL — the chapter's own entity-scoped pool, threaded from
  # `BluebookBuilder#aggregate_impl` the same way
  # `@chapter_named_givens` is, and passed straight through
  # (unchanged) to every top-level piece this aggregate builds
  # (`#drain_pending!`). See `EntityBuilder#given_impl`'s own
  # comment for what this closes.
  @chapter_entity_named_givens   = chapter_entity_named_givens
  @chapter_entity_pending_givens = chapter_entity_pending_givens
  # DEFERRED CONSTRUCTION — `entity`/`command`/`query` push a
  # pending descriptor here instead of building immediately; see
  # `#drain_pending!`'s own comment for why.
  @pending_entities = []
  @pending_commands = []
  @pending_queries  = []
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method in the class Hecks::Bluebook::DSL::WordGate

Class Method Details

.build(name, chapter_named_givens: {}, chapter_pending_givens: [], chapter_entity_named_givens: {}, chapter_entity_pending_givens: [], &block) ⇒ Object



488
489
490
491
492
493
494
495
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 488

def self.build(name, chapter_named_givens: {}, chapter_pending_givens: [],
               chapter_entity_named_givens: {}, chapter_entity_pending_givens: [], &block)
  builder = new(name, chapter_named_givens: chapter_named_givens, chapter_pending_givens: chapter_pending_givens,
                      chapter_entity_named_givens: chapter_entity_named_givens,
                      chapter_entity_pending_givens: chapter_entity_pending_givens)
  builder.instance_eval(&block) if block
  builder.build
end

Instance Method Details

#belongs_to_impl(type, as: nil, optional: false) ⇒ Object



183
184
185
186
187
188
189
190
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 183

def belongs_to_impl(type, as: nil, optional: false)
  return legacy_has_one(type, as: as, optional: optional) if MetaValidator.shadow_parsing?

  target = Naming.demodulise(type)
  @reference_targets << target
  relationship_attribute(target, :belongs_to, as || Naming.snake(target).to_sym,
                         optional: optional)
end

#buildObject



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 453

def build
  drain_pending!
  resolve_pending_identity!
  seal_mutation_targets
  seal_query_targets
  seal_defaults
  seal_lifecycle_guards
  seal_projected_fields
  seal_correction_targets

  ir = Aggregate.new(
    name:              @name,
    description:       @description,
    attributes:        attributes,
    value_objects:     @value_objects + closed_sets,
    commands:          @commands,
    invariants:        @invariants,
    preconditions:     @named_givens.values,
    projected_fields:  @projected_fields,
    identified_by:     @identity_paths,
    lifecycle:         @lifecycle,
    entities:          @entities,
    queries:           @queries,
    policies:          @policies,
    reference_targets: @reference_targets + entity_reference_targets,
    provenance:        @provenance
  )

  # After the IR exists, on purpose : a reference is declared IN the
  # aggregate, and the aggregate the IR graph knows is `ir`, not the
  # builder.
  stamp_references(ir)
  ir
end

#command_impl(name, from: nil, &block) ⇒ Object

from: — LIFECYCLE STATE BECOMES A COMMAND GUARD (S10, ADR 0025) — command "Debit", from: "open" replaces given ("account is open") { status == "open" }, written 35 times in two wordings across the corpus. Checked against THIS aggregate's own lifecycle field (Admissibility#enforce_ lifecycle_guard) — never a target state, never a transition: the lifecycle already declares which states exist, so naming the legal ones is checkable against it, where a free-text given could drift out of sync with the state machine and did.



290
291
292
293
294
295
296
297
298
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 290

def command_impl(name, from: nil, &block)
  # The verb is declared ON this aggregate — the owner `acts_on` answers
  # with — stamped by `Aggregate#initialize` once the aggregate
  # exists. An ENTITY's commands take the entity as their owner instead,
  # at the entity's own declaration. NOT built here — see
  # `#drain_pending!`'s own comment for why this only queues a
  # descriptor.
  @pending_commands << [name, from, block]
end

#description(value) ⇒ Object



58
59
60
61
62
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 58

def description(value)
  # moved to the language: Description invariant, on Root.Declare

  @description = value
end

#entity_impl(name, &block) ⇒ Object

A piece is declared IN this aggregate — its owner is stamped by Aggregate#initialize, once the aggregate exists. Its own commands were given the piece as their owner when it was declared, so the chain closes as chapter -> aggregate -> entity -> command. NOT built here — see #drain_pending!'s own comment for why this only queues a descriptor.

A PRECONDITION SHARED ACROSS SIBLING PIECES, DECLARED ONCE — one level wider than round 4's own EntityBuilder#given (shared across ONE piece's own commands): @entity_named_givens is the SAME hash threaded into EVERY piece this aggregate builds, so a piece's own entity-level given(desc) { block } write-throughs into it, and any OTHER piece's own command can reference it back bare, the identical description/canonical, evaluated in ITS OWN parent-relative context. Real, live corpus this closes: SafeDepositBox's Visit/KeyIssuance — two DIFFERENT pieces under one head, each independently typing given("customer is active") { parent.customer.status == "active" } byte for byte, which neither the aggregate's OWN "customer is active" (a DIFFERENT canonical — bare customer.status, not parent.customer.status, wrong scope for a piece's own command to evaluate) nor round 4's single-piece given could reach.



218
219
220
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 218

def entity_impl(name, &block)
  @pending_entities << [name, block]
end

#given_impl(description, declared_by: nil, &predicate) ⇒ Object

A PRECONDITION SHARED ACROSS COMMANDS, DECLARED ONCE (S10, ADR 0025) — an aggregate-level given, block required, stored by its own description rather than appended anywhere: a command names it back (given("customer is active"), no block of its own) rather than re-typing the predicate, so there is one description and therefore one refusal message no matter which command a caller hits. DECLARE BEFORE THE COMMANDS THAT REFERENCE IT — resolution happens at the referencing command's OWN build time (CommandBuilder#given), against whatever this aggregate has declared SO FAR, the one ordering constraint this word carries that identified_by/attribute do not. BARE — NO BLOCK — REFERENCES a SIBLING AGGREGATE's own already-declared precondition, one level wider than the existing bare-command-references-its-own-aggregate shape (CommandBuilder#reference_named_given): SafeDepositBox/ OnboardingCase both name back Account's own "customer is active" rather than retyping customer.status == "active" a third and fourth time. Resolved against @chapter_named_givens — see BluebookBuilder#aggregate's own comment for how that pool is threaded, and docs/implemented/resolution-rules/chapter-given.md for the full algorithm and its known limitations (a bare reference trusts its own author to have verified the SAME canonical predicate applies — this mechanism does not, and cannot, check that itself; see that doc for which real corpus cases do and do not qualify).

declared_by: DISAMBIGUATES the same description meaning TWO genuinely different predicates chapter-wide — real, live: Account's own "customer is active" reads bare customer.status (a DIRECT reference_to Customer); ATMCard's own (shared onward with CardPayment/ExternalTransfer/ ScheduledPayment/Statement) reads account.customer.status (reached THROUGH Account) — the identical business fact, a genuinely different runtime path, correctly kept as the SAME domain wording rather than invented a second spelling for "the same idea, one more hop away" (S10, ADR 0025's own "one idea, one spelling"). Omit it when the description is unambiguous chapter-wide (the common case, and the ONLY case this took before this parameter existed) — required only once a SECOND, textually-different canonical registers under the same description; see reference_named_chapter_given's own ambiguity error for how that surfaces. RENAMED FROM given — item #13's full metaprogrammed dispatch (slice 4b), same reasoning as reference_to_impl above: bootstrap-reachable, in BOOTSTRAP_CALLS_FALLBACK.



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 345

def given_impl(description, declared_by: nil, &predicate)
  return reference_named_chapter_given(description, declared_by: declared_by) unless predicate

  named = build_rule(Given, description, predicate, owner_name: @name, word: "given",
                      extraction_failure: "its source could not be read, so no other runtime could ever evaluate it")
  @named_givens[description] = named
  # WRITE-THROUGH, first-declared-wins PER OWNER — keyed by
  # [description, this aggregate's own name], not description
  # alone: two DIFFERENT aggregates independently declaring the
  # SAME description are two DISTINCT candidates a later bare
  # reference chooses between (via `declared_by:` once there is
  # more than one), never silently merged into one slot the way
  # a bare description-only key would.
  @chapter_named_givens[description] ||= {}
  @chapter_named_givens[description][@name] ||= named
end

#has_many_impl(type, as: nil, **legacy_options) ⇒ Object

has_many/has_one/belongs_to were LEGACY (ADR 0025, "References") — sugar over reference_to that collapsed to an anonymous reference and, for has_many, LIED (singularised its target and minted one scalar, so film.backers read nil and never []). Wave 6 (identity-and-relationships arc) un-deprecates all three for real: a relationship word now retains the author's domain concept in IR — still stored as one or more target identities, but no longer collapsed to a bare reference_to during assembly. MetaValidator.shadow_parsing? still routes to legacy_has_many/legacy_has_one so frozen era text written under the OLD (lying/collapsing) meaning still parses the way it did when it was written — real, if rare corpus: "Combined corpus uses: one."

RENAMED FROM has_many/has_one/belongs_to — item #13's full metaprogrammed dispatch (slice 4). Each Keyword row's own calls: names the matching _impl; not bootstrap-reachable (no core/attached chapter uses one of these to describe itself), so no BOOTSTRAP_CALLS_FALLBACK entry is needed, unlike attribute/role.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 158

def has_many_impl(type, as: nil, **legacy_options)
  if MetaValidator.shadow_parsing?
    return legacy_has_many(type, as: as, optional: legacy_options.fetch(:optional, false))
  end

  unless legacy_options.empty?
    raise Malformed, "#{@name}.has_many takes no #{legacy_options.keys.first}: — an empty list already means none"
  end

  plural = Naming.demodulise(type)
  target = Naming.singularize(plural)
  @reference_targets << target
  relationship_attribute(target, :has_many, as || Naming.snake(plural).to_sym,
                         list: true)
end

#has_one_impl(type, as: nil, optional: false) ⇒ Object



174
175
176
177
178
179
180
181
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 174

def has_one_impl(type, as: nil, optional: false)
  return legacy_has_one(type, as: as, optional: optional) if MetaValidator.shadow_parsing?

  target = Naming.demodulise(type)
  @reference_targets << target
  relationship_attribute(target, :has_one, as || Naming.snake(target).to_sym,
                         optional: optional)
end

#invariant_impl(description, &predicate) ⇒ Object

THE AGGREGATE BOUNDARY IS WHAT AN INVARIANT DEFINES (S10, ADR 0025 — "Rules") — checked after every command, before save, the same way a value object's already is (ValueObjectBuilder#invariant, whose own shape this mirrors exactly). Today invariant lived only inside value_object; an aggregate-level rule had nowhere to live, so "the balance never goes negative" was three different given/ensures texts across banking's six balance-moving commands, and the four that only increase it said nothing at all — completeness depended on someone noticing which commands could decrease it. RENAMED FROM invariant — item #13's full metaprogrammed dispatch (slice 4b), same reasoning as given_impl above.



448
449
450
451
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 448

def invariant_impl(description, &predicate)
  @invariants << build_rule(Invariant, description, predicate, owner_name: @name, word: "invariant",
                             extraction_failure: "it would be a rule the IR cannot carry")
end

#lifecycle_impl(field, default:, &block) ⇒ Object



192
193
194
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 192

def lifecycle_impl(field, default:, &block)
  @lifecycle = LifecycleBuilder.build(field, default: default, &block)
end

#policy_impl(name, &block) ⇒ Object



226
227
228
229
230
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 226

def policy_impl(name, &block)
  reaction = PolicyBuilder.build(name, &block)
  reaction.aggregate = @name
  @policies << reaction
end

#projects_impl(name, from:) ⇒ Object

A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12, ADR 0025 — "Consistency across aggregate boundaries"). A given/ensures/invariant used to reach through a reference_to at RULE-EVALUATION TIME (References# dereference, a live query against another aggregate's own repository, unbounded and inconsistent with the "a rule reads only this record" model everywhere else) — projects is what replaces that: projects :customer_status, from: :"customer. status" declares that THIS aggregate holds its own copy of Customer's own :status, kept fresh by a REBUILD SWEEP (Runtime::ProjectionRebuild) rather than read live. A rule then reads customer_status the same way it reads any other local field — no dot, no reference walk.

from: NAMES THE LOCAL REFERENCE, not the target aggregate — customer, the attribute THIS aggregate's own reference_to Customer already minted, not Customer the type — so two references to the same aggregate (aliased differently) can each carry their own projection without ambiguity. The TARGET field's own existence cannot be checked here: the target aggregate does not exist yet while THIS one is still being declared (the same reason a query's own hop tail is checked by BluebookBuilder#validate_query_hops!, once every aggregate in the chapter is real, not by AggregateBuilder itself) — validate_projected_fields! is where that half happens.



124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 124

def projects_impl(name, from:)
  reference, _, remote_field = from.to_s.rpartition(".")

  if reference.empty? || remote_field.empty?
    raise Malformed,
          "#{@name}.projects :#{name} names #{from.inspect}, which is not " \
          "reference.field — say which reference and which field on it, e.g. " \
          "from: :\"customer.status\""
  end

  @projected_fields << ProjectedField.new(name: name.to_sym, reference: reference.to_sym,
                                          remote_field: remote_field.to_sym)
end

#provenance_impl(from:) ⇒ Object

ORIGIN, not runtime identity — a concept adopted from a canonical source (§28) names where it came from without that fact ever touching hecks_fqn/dispatch. Captured raw, the same way attribute ..., default: { value: "small" } captures a literal Hash untouched — no re-parsing, no structure imposed beyond "whatever the author wrote." RENAMED FROM provenance/projects/lifecycle/entity/ query/policy/command (all below) — item #13's full metaprogrammed dispatch (slice 4c). All bootstrap-reachable (used throughout the core/attached chapters), all in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.



75
76
77
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 75

def provenance_impl(from:)
  @provenance = from
end

#query_impl(name, &block) ⇒ Object



222
223
224
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 222

def query_impl(name, &block)
  @pending_queries << [name, block]
end

#reference_to_impl(type, as: nil, optional: false) ⇒ Object

optional: — matching CommandBuilder#reference_to's own signature, which already had it; this one never forwarded it to attribute_impl() even though attribute_impl() itself already accepts it. A real gap: an aggregate that can point at ONE OF several targets (Item's own personal_list_id/ camping_list_id, never both) needs each reference optional on the aggregate's own persisted schema, not just as a command's input. RENAMED FROM reference_to — item #13's full metaprogrammed dispatch (slice 4b). Bootstrap-reachable (every core/attached grammar chapter uses reference_to to describe itself), so also named in GenericDispatch::BOOTSTRAP_CALLS_FALLBACK.



91
92
93
94
95
96
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 91

def reference_to_impl(type, as: nil, optional: false)
  target = Naming.demodulise(type)
  @reference_targets << target
  relationship_attribute(target, :reference_to,
                         as || default_reference_name(target), optional: optional)
end

#value_object(name, type = nil, &block) ⇒ Object

builder.closed_sets TOO, not only builder.build — a REAL, previously-unreachable gap this exact fix exposed: a value_object's own INLINE attribute :x, one_of(...) (now legal — S3, ADR 0025 removed the wrong-arity collision that used to make this crash before it could ever matter) synthesises its own anonymous value object via the SAME AttributeCollector#closed_ sets mechanism an aggregate's own attributes already use — and nothing installed it anywhere. Box.attributes said size: "Size" while no "Size" value object existed in the whole domain: a dangling type name, not a working closed set. Flattened into THIS aggregate's own @value_objects, the identical move @value_objects + closed_sets already makes for the aggregate's own direct attributes (see this file's other 5 call sites). type — THE BARE SHORTHAND (single-attribute value objects): value_object :Price, Integer declares a value object with exactly one attribute, NAMED value, of that type — pure sugar for value_object("Price") { attribute :value, Integer }, routed through the SAME attribute_impl the block form's own attribute line reaches (so the quoted-text-type refusal, one_of(...)/list_of(...) synthesis, everything an attribute line already does, applies unchanged rather than being re-derived here). The name value is not arbitrary: a single-attribute value object is a NAME for a scalar, not a

genuine group ([[feedback_name_the_scalar_field]], `Behaviour

ValueObject#sole_attribute), and value is what the language guarantees EVERY sole field answers to at runtime regardless of its declared name (Runtime::Value#method_missing`'s alias) — so the shorthand simply declares it under the canonical name directly. Type AND block together are refused: the block exists to say what the fields are, and the type just said it — two answers to one question is an authoring error, never a merge. NEITHER type NOR block keeps its historical behavior untouched (an empty attribute list — judged, or not, by the language downstream, the same as before this parameter existed).



266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 266

def value_object(name, type = nil, &block)
  if type && block
    raise Malformed,
          "#{name} declares both a type (#{type.inspect}) and a block — " \
          "value_object #{name.inspect}, Type is sugar for a block declaring " \
          "exactly one attribute named :value; write one form or the other, never both"
  end

  builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
  builder.attribute_impl(:value, type) if type
  builder.instance_eval(&block) if block
  @value_objects << builder.build
  @value_objects.concat(builder.closed_sets)
end