Class: Hecks::Bluebook::DSL::AggregateBuilder
- Inherits:
-
Object
- Object
- Hecks::Bluebook::DSL::AggregateBuilder
- Includes:
- AttributeCollector, IdentityDeclaration, RuleReference, WordGate
- Defined in:
- lib/hecks/bluebook/dsl/aggregate_builder.rb
Constant Summary collapse
- GRAMMAR_CONTEXT =
"Aggregate"
Constants included from WordGate
Constants included from RuleReference
RuleReference::BOOTSTRAP_FALLBACK
Class Method Summary collapse
Instance Method Summary collapse
- #belongs_to_impl(type, as: nil, optional: false) ⇒ Object
- #build ⇒ Object
-
#command_impl(name, from: nil, &block) ⇒ Object
from:— LIFECYCLE STATE BECOMES A COMMAND GUARD (S10, ADR 0025) —command "Debit", from: "open"replacesgiven ("account is open") { status == "open" }, written 35 times in two wordings across the corpus. - #description(value) ⇒ Object
-
#entity_impl(name, &block) ⇒ Object
A piece is declared IN this aggregate — its owner is stamped by
Aggregate#initialize, once the aggregate exists. -
#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. -
#has_many_impl(type, as: nil, **legacy_options) ⇒ Object
has_many/has_one/belongs_towere LEGACY (ADR 0025, "References") — sugar overreference_tothat collapsed to an anonymous reference and, forhas_many, LIED (singularised its target and minted one scalar, sofilm.backersreadniland never[]). - #has_one_impl(type, as: nil, optional: false) ⇒ Object
-
#initialize(name, chapter_named_givens: {}, chapter_pending_givens: []) ⇒ AggregateBuilder
constructor
A new instance of AggregateBuilder.
-
#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). - #lifecycle_impl(field, default:, &block) ⇒ Object
- #policy_impl(name, &block) ⇒ Object
-
#projects_impl(name, from:) ⇒ Object
A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12, ADR 0025 — "Consistency across aggregate boundaries").
-
#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. - #query_impl(name, &block) ⇒ Object
-
#reference_to_impl(type, as: nil, optional: false) ⇒ Object
optional:— matchingCommandBuilder#reference_to's own signature, which already had it; this one never forwarded it toattribute_impl()even thoughattribute_impl()itself already accepts it. -
#value_object(name, &block) ⇒ Object
builder.closed_setsTOO, not onlybuilder.build— a REAL, previously-unreachable gap this exact fix exposed: a value_object's own INLINEattribute :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 SAMEAttributeCollector#closed_ setsmechanism an aggregate's own attributes already use — and nothing installed it anywhere.
Methods included from RuleReference
build_rule, lookup, resolve_hash_chain, resolve_owner_keyed, resolve_sibling_scan, verify_resolves_via!
Methods included from IdentityDeclaration
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 |
#build ⇒ Object
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, **) if MetaValidator.shadow_parsing? return legacy_has_many(type, as: as, optional: .fetch(:optional, false)) end unless .empty? raise Malformed, "#{@name}.has_many takes no #{.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 |