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 +4 -4
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -1
- data/lib/hecks/adapters/driven/sql_query_builder.rb +12 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +30 -1
- data/lib/hecks/bluebook/expression/resolver.rb +25 -0
- data/lib/hecks/fuzzing/properties.rb +49 -4
- data/lib/hecks/language/bluebook/aggregate.bluebook +17 -0
- data/lib/hecks/runtime/value.rb +54 -4
- data/lib/hecks/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d13701a926b6be040b496d5e582a9d4fd99ad878372f5345636ef074aa6e4e8
|
|
4
|
+
data.tar.gz: 95fc7593502393bd0d698aeaac1b9419dc6e00b78e12f11ad6740a2008a0df0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
Ports::Query::InMemory.
|
|
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"
|
data/lib/hecks/runtime/value.rb
CHANGED
|
@@ -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
|
|
42
|
-
def key?(field) = @fields.key?(field
|
|
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
|
|
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)
|
|
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
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.
|
|
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:
|