hecks 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82b36c04d3c930c3f3193aeb23d208c170ab95507dfbac4842b8dae59168a732
4
- data.tar.gz: 1cac5931a30cf658980cc65cf8c31a90b36deae5460a9ed6846902d51faa4630
3
+ metadata.gz: 5d13701a926b6be040b496d5e582a9d4fd99ad878372f5345636ef074aa6e4e8
4
+ data.tar.gz: 95fc7593502393bd0d698aeaac1b9419dc6e00b78e12f11ad6740a2008a0df0e
5
5
  SHA512:
6
- metadata.gz: a90f3c1372ac1a9d3755fe3fbc83a9036cea933a9b63f9517bd52e1010c397c6e2dacd21c6a5030b1fdebbca4bb339c1cc651923b20c1bd9aa1160161ccd7d23
7
- data.tar.gz: 6d1069bd54229fddc5befad9ff9afb727fba894816895acc59fcfec20000c765e0444cdd0fd23dc9a37a5a3478c209a0005c836f5351483fbadbfbebb7742566
6
+ metadata.gz: 9b65062cd42566cb0f2e4d5c45b12b1990133850964e117a6827b917d5b293245855a292ccd698c12d982de268a1554029a7a079a03c2cd7e99148eba1f7af47
7
+ data.tar.gz: 9b98481d47cfcbd09e72e5d53226287fbed298d2d1ae77ebbd48b133c7fb9100be1531daca0be92728976ef11eb87979e5847306b4eed628308dfebfdf83014f
@@ -43,7 +43,15 @@ module Hecks
43
43
  vo = aggregate.value_object(attribute.type)
44
44
  return field.to_s unless vo
45
45
 
46
- member = vo.attributes.find { |a| %w[Integer Float].include?(a.type) }&.name || "value"
46
+ # Numeric member first, then the SOLE attribute whatever it is
47
+ # named (single-attribute value objects strictly answer `.value`
48
+ # — the same generalization `SqlQueryBuilder#query_expression`
49
+ # makes for the column side, kept in lockstep so Memory and SQL
50
+ # order the identical rows identically), and only then the bare
51
+ # `value` convention — now purely a backstop for the multi-field
52
+ # non-numeric shape neither rule can honestly pick a field for.
53
+ member = vo.attributes.find { |a| %w[Integer Float].include?(a.type) }&.name ||
54
+ vo.sole_attribute&.name || "value"
47
55
  "#{name}.#{member}"
48
56
  end
49
57
  end
@@ -151,7 +151,18 @@ module Hecks
151
151
  end
152
152
  member ||= if path.empty? && attribute && value_object?(attribute)
153
153
  object = @aggregate.value_object(attribute.type)
154
- Forms::ValueObjectShape.numeric_member(object)&.name
154
+ # A SOLE attribute is the fallback when no member is
155
+ # numeric — a single-attribute value object IS its one
156
+ # field whatever that field is named (`Behaviour::
157
+ # ValueObject#sole_attribute`, the same strict rule
158
+ # `Runtime::Value`'s own `.value` alias enforces), so
159
+ # `EmailAddress{address}` compiles to `$.address`
160
+ # rather than falling through to the dialects' own
161
+ # `member || "value"` convention and silently matching
162
+ # nothing. Multi-field non-numeric shapes still fall
163
+ # through to that convention, unchanged — there is no
164
+ # single field to honestly pick for them here either.
165
+ (Forms::ValueObjectShape.numeric_member(object) || object.sole_attribute)&.name
155
166
  end
156
167
  nested_expression(name, path, member)
157
168
  end
@@ -242,8 +242,37 @@ module Hecks
242
242
  # into THIS aggregate's own `@value_objects`, the identical move
243
243
  # `@value_objects + closed_sets` already makes for the aggregate's
244
244
  # own direct attributes (see this file's other 5 call sites).
245
- def value_object(name, &block)
245
+ # `type` — THE BARE SHORTHAND (single-attribute value objects):
246
+ # `value_object :Price, Integer` declares a value object with
247
+ # exactly one attribute, NAMED `value`, of that type — pure sugar
248
+ # for `value_object("Price") { attribute :value, Integer }`,
249
+ # routed through the SAME `attribute_impl` the block form's own
250
+ # `attribute` line reaches (so the quoted-text-type refusal,
251
+ # `one_of(...)`/`list_of(...)` synthesis, everything an attribute
252
+ # line already does, applies unchanged rather than being
253
+ # re-derived here). The name `value` is not arbitrary: a
254
+ # single-attribute value object is a NAME for a scalar, not a
255
+ # genuine group ([[feedback_name_the_scalar_field]], `Behaviour::
256
+ # ValueObject#sole_attribute`), and `value` is what the language
257
+ # guarantees EVERY sole field answers to at runtime regardless of
258
+ # its declared name (`Runtime::Value#method_missing`'s alias) —
259
+ # so the shorthand simply declares it under the canonical name
260
+ # directly. Type AND block together are refused: the block exists
261
+ # to say what the fields are, and the type just said it — two
262
+ # answers to one question is an authoring error, never a merge.
263
+ # NEITHER type NOR block keeps its historical behavior untouched
264
+ # (an empty attribute list — judged, or not, by the language
265
+ # downstream, the same as before this parameter existed).
266
+ def value_object(name, type = nil, &block)
267
+ if type && block
268
+ raise Malformed,
269
+ "#{name} declares both a type (#{type.inspect}) and a block — " \
270
+ "value_object #{name.inspect}, Type is sugar for a block declaring " \
271
+ "exactly one attribute named :value; write one form or the other, never both"
272
+ end
273
+
246
274
  builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
275
+ builder.attribute_impl(:value, type) if type
247
276
  builder.instance_eval(&block) if block
248
277
  @value_objects << builder.build
249
278
  @value_objects.concat(builder.closed_sets)
@@ -744,9 +744,34 @@ module Hecks
744
744
  # `#to_h`), so this is safe for the existing `field.value`-
745
745
  # shaped dotted lookups too -- they already returned a raw
746
746
  # scalar and are unaffected.
747
+ # UPDATE (single-element value objects strictly answer `.value`):
748
+ # the unwrap used to gate on the sole key being literally NAMED
749
+ # `:value` — correct for the shorthand/closed-set shapes that
750
+ # motivated it, but a lie of omission for `Money{amount}` and
751
+ # every other single-field value object whose author picked a
752
+ # domain name for the field: the SAME "this VO IS its scalar"
753
+ # reading ([[feedback_name_the_scalar_field]], `Behaviour::
754
+ # ValueObject#sole_attribute`) applies regardless of what the
755
+ # sole field happens to be called, and the name gate made a bare
756
+ # `balance > 0` work for a `Balance{value}` while silently
757
+ # comparing a whole VO for a `Balance{amount}`. Now the COUNT is
758
+ # the gate, never the name. A declared `Runtime::Value` reads
759
+ # its own `sole_attribute` (the declaration's answer, not the
760
+ # stored hash's); any OTHER to_h-able (a Struct, a bespoke
761
+ # wrapper with no declaration to consult) keeps the original
762
+ # `{value: X}`-only unwrap, so nothing that never was a value
763
+ # object gains a surprise unwrapping. `rust/src/kernel/json.rs`'s
764
+ # `impl Fielded for Json` mirrors the count-only reading on the
765
+ # Rust side — change them in lockstep or rust_conformance
766
+ # diverges.
747
767
  def unwrap_scalar(value)
748
768
  return value unless value.respond_to?(:to_h) && !value.is_a?(Hash) && !value.is_a?(Array)
749
769
 
770
+ if value.respond_to?(:value_object)
771
+ sole = value.value_object.sole_attribute
772
+ return sole ? value[sole.name] : value
773
+ end
774
+
750
775
  hash = value.to_h
751
776
  hash.size == 1 && hash.key?(:value) ? hash[:value] : value
752
777
  end
@@ -321,7 +321,8 @@ module Hecks
321
321
 
322
322
  domain, aggregate_name, = Naming.split_verb(asked[:query])
323
323
  args = asked[:args] || {}
324
- rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args)
324
+ rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args,
325
+ bluebooks: bluebooks)
325
326
  ordered = Ports::Query::Ordering.apply(
326
327
  rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
327
328
  ) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
@@ -361,21 +362,65 @@ module Hecks
361
362
  # same way #eligible_rows' own rows are, since a stable sort
362
363
  # (Ordering.apply's own `identity:`) and the real answer's own
363
364
  # `record.state.merge(id: record.id)` both need it.
364
- def query_eligible_rows(instances, domain, aggregate_name, wheres, args)
365
+ # `bluebooks:` needed ONLY to recognise and resolve a `/` HOP
366
+ # clause (`engagement/client/status`, hop_chain.bluebook's own
367
+ # PricedAboveViaEngagement): a hop's head names one of the OWNING
368
+ # aggregate's declared references, and only the declaration graph
369
+ # can say which attribute that is and which aggregate it targets.
370
+ # A local clause never consults it. Latent gap this closed, found
371
+ # by the fuzzer itself the first time a generated sequence ever
372
+ # built a full hop chain AND had its paged query answer a row
373
+ # (seed 1, the moment scalar_value_objects.bluebook joined the
374
+ # fixtures corpus and shifted every seeded draw): the recompute
375
+ # dug `engagement/client/status` as a LOCAL dotted path, found
376
+ # nil, and declared every genuinely-eligible row ineligible — a
377
+ # false property violation against a correct runtime answer,
378
+ # reproducible on an untouched main with this same 4-step script.
379
+ def query_eligible_rows(instances, domain, aggregate_name, wheres, args, bluebooks: {})
380
+ aggregate = bluebooks[domain]&.aggregate(aggregate_name)
365
381
  prefix = "#{domain}::#{aggregate_name}#"
366
382
  instances.filter_map do |key, state|
367
383
  next unless key.start_with?(prefix)
368
384
 
369
385
  row = state.merge(id: key.split("#").last)
370
386
  next unless wheres.all? do |clause|
371
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, clause.field))
372
- Ports::Query::InMemory.holds?(clause, held, args)
387
+ resolved = resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
388
+ held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, resolved.field))
389
+ Ports::Query::InMemory.holds?(resolved, held, args)
373
390
  end
374
391
 
375
392
  row
376
393
  end
377
394
  end
378
395
 
396
+ # `Runtime::ReferenceHop#fold`, independently restated over the
397
+ # replay's own `:instances_at` snapshot instead of live
398
+ # repositories — the same shape every other recompute in this
399
+ # file takes (never the runtime's own code path, or the property
400
+ # would be checking the runtime against itself). One hop peels
401
+ # off the head (`HopPath.next_hop`, the identical one-step
402
+ # primitive the live fold uses), the inner clause recurses
403
+ # through `query_eligible_rows` against the TARGET's own
404
+ # snapshot rows (so a multi-hop tail resolves hop by hop, exactly
405
+ # as the live path's own recursion does), and the ids that
406
+ # answered fold back as the same local `in` membership clause the
407
+ # live fold builds. A clause with no `/`, or one whose head this
408
+ # aggregate's declarations cannot resolve, passes through
409
+ # untouched and evaluates locally as it always did.
410
+ def resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
411
+ return clause unless aggregate && QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
412
+
413
+ hop, rest = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
414
+ target = hop.target
415
+ return clause unless target
416
+
417
+ inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
418
+ ids = query_eligible_rows(instances, domain, target.hecks_name, [inner], args, bluebooks: bluebooks)
419
+ .map { |row| row[:id].to_s }.uniq
420
+
421
+ QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
422
+ end
423
+
379
424
  # `QueryInterpreter#resolve_query_value`, reproduced: a declared
380
425
  # limit/offset is either a literal or a Symbol naming an argument
381
426
  # the caller supplied.
@@ -509,6 +509,15 @@ Hecks.bluebook "Bluebook" do
509
509
  member word: "query", context: "Aggregate", body: "keywords", inner: "Query", opens: "Query", fills: "", calls: "query_impl"
510
510
  member word: "policy", context: "Aggregate", body: "keywords", inner: "Policy", opens: "Policy", fills: "", calls: "policy_impl"
511
511
  member word: "value_object", context: "Aggregate", body: "keywords", inner: "ValueObject", opens: "ValueObject", fills: "value_objects"
512
+ # THE BARE SHORTHAND — `value_object "Price", Integer`, no block:
513
+ # a second row for the SAME word with `body: "none"`, the exact
514
+ # two-row move `identified_by` (above) already makes for its own
515
+ # block/blockless split — the body gate picks whichever row
516
+ # matches how the line was actually written. Declares a value
517
+ # object with exactly one attribute, named `value`, of the given
518
+ # type; sugar for the block form's own single `attribute :value,
519
+ # Type` line (`AggregateBuilder#value_object`'s own comment).
520
+ member word: "value_object", context: "Aggregate", body: "none", inner: "", opens: "", fills: "value_objects"
512
521
  member word: "command", context: "Aggregate", body: "keywords", inner: "Command", opens: "Command", fills: "", calls: "command_impl"
513
522
  member word: "attribute", context: "Aggregate", body: "none", inner: "", opens: "", fills: "attributes", calls: "attribute_impl"
514
523
  member word: "invariant", context: "Aggregate", body: "source", inner: "", opens: "", fills: "invariants", calls: "invariant_impl"
@@ -558,6 +567,14 @@ Hecks.bluebook "Bluebook" do
558
567
  member keyword: "query", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
559
568
  member keyword: "policy", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
560
569
  member keyword: "value_object", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
570
+ # The bare shorthand's own TYPE argument — optional, because the
571
+ # block form gives no type at all (its attributes carry their own);
572
+ # `kind: "constant"` for the same reason `attribute`'s own type
573
+ # argument (below) is: a type is a bare constant, never quoted
574
+ # text. Type-AND-block is refused by the builder itself
575
+ # (`AggregateBuilder#value_object`), not here — an argument row
576
+ # cannot see the body, only the call.
577
+ member keyword: "value_object", context: "Aggregate", at: "2", named: "", kind: "constant", required: "false", fills: "type"
561
578
  member keyword: "command", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
562
579
  member keyword: "command", context: "Aggregate", at: "", named: "from", kind: "literal", required: "false", fills: "from"
563
580
  member keyword: "attribute", context: "Aggregate", at: "1", named: "", kind: "symbol", required: "true", fills: "name"
@@ -38,8 +38,8 @@ module Hecks
38
38
  end
39
39
 
40
40
  def type_name = @value_object.hecks_name
41
- def [](field) = @fields[field.to_sym]
42
- def key?(field) = @fields.key?(field.to_sym)
41
+ def [](field) = @fields[resolve_field(field)]
42
+ def key?(field) = @fields.key?(resolve_field(field))
43
43
  def to_h = @fields.transform_values { |value| self.class.materialize(value) }
44
44
  def to_json(*) = JSON.generate(to_h)
45
45
 
@@ -48,7 +48,7 @@ module Hecks
48
48
  end
49
49
 
50
50
  def with(field, value)
51
- self.class.build(@value_object, @fields.merge(field.to_sym => value))
51
+ self.class.build(@value_object, @fields.merge(resolve_field(field) => value))
52
52
  end
53
53
 
54
54
  def self.materialize(value)
@@ -114,11 +114,61 @@ module Hecks
114
114
  def method_missing(name, *args)
115
115
  return @fields[name] if @fields.key?(name)
116
116
 
117
+ # THE LANGUAGE RULE, not a convenience: ANY value object with
118
+ # exactly one declared attribute answers `.value`, whatever that
119
+ # attribute is actually named — a single-attribute value object
120
+ # is a NAME for a scalar, not a genuine group
121
+ # ([[feedback_name_the_scalar_field]], `Behaviour::ValueObject#
122
+ # sole_attribute`), so `money.value` reads `Money`'s own `amount`
123
+ # exactly as `label.value` reads a shorthand-declared `value`.
124
+ # AFTER the real-field lookup above, on purpose: a field
125
+ # literally named `value` is already answered there (and IS the
126
+ # sole attribute whenever the count is one), so this branch only
127
+ # ever aliases, never shadows. A MULTI-attribute value object
128
+ # keeps its NoMethodError — `sole_attribute` answers nil for it,
129
+ # and falling through to `super` is exactly the refusal it
130
+ # always gave: with two or more fields there is no single value
131
+ # `.value` could honestly mean.
132
+ if name == :value
133
+ sole = @value_object.sole_attribute
134
+ return @fields[sole.name] if sole
135
+ end
136
+
117
137
  super
118
138
  end
119
139
 
120
140
  def respond_to_missing?(name, include_private = false)
121
- @fields.key?(name) || super
141
+ return true if @fields.key?(name)
142
+ return true if name == :value && @value_object.sole_attribute
143
+
144
+ super
145
+ end
146
+
147
+ private
148
+
149
+ # THE `.value` ALIAS FOR INDEXED ACCESS — the same language rule
150
+ # `method_missing` above enforces for method reads, applied to
151
+ # `[]`/`key?`/`with`: `:value` names a single-attribute value
152
+ # object's sole field whatever that field is actually called. A
153
+ # REAL key always wins first (a field literally named `value` is
154
+ # its own answer, and is the sole attribute anyway whenever the
155
+ # count is one), so this only ever resolves a `:value` that would
156
+ # otherwise MISS — it can never redirect a genuine field read.
157
+ # `with(:value, x)` in particular NEEDS this: merging a literal
158
+ # `:value` key beside a sole field named `amount` would build a
159
+ # two-key hash for a one-field shape and be refused (or worse,
160
+ # stored) downstream — aliasing at the merge is what keeps the
161
+ # write half of the rule as true as the read half.
162
+ def resolve_field(field)
163
+ sym = field.to_sym
164
+ return sym if @fields.key?(sym)
165
+
166
+ if sym == :value
167
+ sole = @value_object.sole_attribute
168
+ return sole.name if sole
169
+ end
170
+
171
+ sym
122
172
  end
123
173
  end
124
174
  end
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "1.0.1"
15
+ VERSION = "1.0.2"
16
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -428,6 +428,8 @@ licenses:
428
428
  metadata:
429
429
  allowed_push_host: https://rubygems.org
430
430
  source_code_uri: https://github.com/heckslabs/hecks
431
+ changelog_uri: https://github.com/heckslabs/hecks/blob/main/CHANGELOG.md
432
+ documentation_uri: https://rubydoc.info/gems/hecks
431
433
  post_install_message:
432
434
  rdoc_options: []
433
435
  require_paths: