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: []) ⇒ 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
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 13

def initialize(name, chapter_named_givens: {}, chapter_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
  # 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: [], &block) ⇒ Object



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

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

Instance Method Details

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



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

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



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 414

def build
  drain_pending!
  resolve_pending_identity!
  seal_mutation_targets
  seal_query_targets
  seal_defaults
  seal_lifecycle_guards
  seal_projected_fields

  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.



251
252
253
254
255
256
257
258
259
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 251

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



48
49
50
51
52
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 48

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.



208
209
210
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 208

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.



306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 306

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.



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 148

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



164
165
166
167
168
169
170
171
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 164

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.



409
410
411
412
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 409

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



182
183
184
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 182

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

#policy_impl(name, &block) ⇒ Object



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

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.



114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 114

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.



65
66
67
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 65

def provenance_impl(from:)
  @provenance = from
end

#query_impl(name, &block) ⇒ Object



212
213
214
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 212

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.



81
82
83
84
85
86
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 81

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, &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).



235
236
237
238
239
240
# File 'lib/hecks/bluebook/dsl/aggregate_builder.rb', line 235

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