hecks 3.2.0 → 3.3.0
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/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +45 -5
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +40 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +28 -4
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +51 -17
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +7 -140
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +80 -14
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +2 -4
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +152 -1
- data/lib/hecks.rb +1 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +31 -16
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +9 -20
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +8 -8
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +23 -13
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +5 -3
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +193 -29
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +18 -8
- data/rust/src/kernel/needs.rs +104 -9
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +24 -30
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- data/rust/build/src/optional_pass.rs +0 -150
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
data/rust/project/read_models.rb
DELETED
|
@@ -1,690 +0,0 @@
|
|
|
1
|
-
require_relative "skip_reason"
|
|
2
|
-
|
|
3
|
-
# Compiles declared read models into the static Rust tables
|
|
4
|
-
# `kernel/read_model.rs` interprets at runtime.
|
|
5
|
-
module RustProjection
|
|
6
|
-
module Projector
|
|
7
|
-
module_function
|
|
8
|
-
|
|
9
|
-
# `group_by` is now always present on the wire (`[]` when undeclared), so
|
|
10
|
-
# eligibility below must check its value, not the key's mere presence.
|
|
11
|
-
READ_MODEL_BARE_KEYS = %i[name description reference_name reference_target query_name aggregate_heads
|
|
12
|
-
wheres order_by offset limit freshness index_hints group_by null_semantics
|
|
13
|
-
authorization count median_field sum_field avg_field min_field max_field
|
|
14
|
-
percentile_field percentile_at any_field all_field].freeze
|
|
15
|
-
|
|
16
|
-
# Returns nil if the read model can be generated as-is, otherwise a
|
|
17
|
-
# short reason string naming what's unsupported.
|
|
18
|
-
def read_model_skip_reason(read_model, aggregates_by_name, unsupported_names)
|
|
19
|
-
extra = read_model.keys.map(&:to_sym) - READ_MODEL_BARE_KEYS
|
|
20
|
-
return read_model_options_skip_reason(extra) if extra.any?
|
|
21
|
-
|
|
22
|
-
# Checked before the root lookup below: a rootless, group_by'd read
|
|
23
|
-
# model has no reference_target, and reporting that as "no matching
|
|
24
|
-
# aggregate head" would be misleading instead of the real reason.
|
|
25
|
-
return group_by_skip_reason(read_model, aggregates_by_name, unsupported_names) if Array(read_model[:group_by]).any?
|
|
26
|
-
|
|
27
|
-
heads = read_model[:aggregate_heads]
|
|
28
|
-
root = heads.find { |head| head[:aggregate].to_s == read_model[:reference_target].to_s }
|
|
29
|
-
# A rootless read model (every head reads its own whole table) has no
|
|
30
|
-
# reference_target at all; only a named target with no matching head,
|
|
31
|
-
# which leaves nothing for the root fetch to key off, is refused.
|
|
32
|
-
if !root && !read_model[:reference_target].to_s.empty?
|
|
33
|
-
return skip("missing_root_head", "declares reference_to #{read_model[:reference_target]}, but includes no matching aggregate head — " \
|
|
34
|
-
"nothing for this generator's own root fetch to key off")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
if root && !aggregates_by_name[root[:aggregate]] && nested_entity_names(aggregates_by_name).include?(root[:aggregate].to_s)
|
|
38
|
-
return entity_head_skip_reason(root[:aggregate], "root", "fetch by id")
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
heads.each do |head|
|
|
42
|
-
reason = read_model_head_skip_reason(head, aggregates_by_name, unsupported_names)
|
|
43
|
-
return reason if reason
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
reason = read_model_options_content_skip_reason(read_model, aggregates_by_name)
|
|
47
|
-
return reason if reason
|
|
48
|
-
|
|
49
|
-
aggregation_skip_reason(read_model, aggregates_by_name)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def read_model_options_skip_reason(extra)
|
|
53
|
-
skip(extra.map(&:to_s).min, "declares #{extra.map(&:to_s).sort.join(', ')} — out of scope for this generator: cursor/" \
|
|
54
|
-
"consistency/inspection are real " \
|
|
55
|
-
"capabilities Ports::Query::InMemory/Ports::Query::Ordering/TenantScope implement that this generator " \
|
|
56
|
-
"does not port (this file's own header has the full argument, the same boundary queries.rb already " \
|
|
57
|
-
"draws for a declared AGGREGATE query); freshness/use_index are never disqualifying on their own — " \
|
|
58
|
-
"neither is read by the in-memory interpreter path this kernel matches")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The many-side heads a where/order_by/offset/limit's own `on:` (or, for an
|
|
62
|
-
# untargeted option, the sole many-side head) could possibly name — every
|
|
63
|
-
# many-side head this read model declares.
|
|
64
|
-
def read_model_many_heads(read_model)
|
|
65
|
-
read_model[:aggregate_heads].select { |head| head[:many] }
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Whether one declared option (its own `:target`, or nil when untargeted)
|
|
69
|
-
# applies to `head`. `target` names the included aggregate by TYPE (ADR
|
|
70
|
-
# 0055), not by its `as:` alias; nil only ever means "the sole many-side
|
|
71
|
-
# head" — `ReadModelBuilder#seal_query_options` refuses an untargeted
|
|
72
|
-
# option whenever a read model declares more than one, so an untargeted
|
|
73
|
-
# option reaching codegen at all is proof there's exactly one to mean.
|
|
74
|
-
def option_target_matches_head?(target, head, many_heads)
|
|
75
|
-
return many_heads.size == 1 && many_heads.first[:as].to_s == head[:as].to_s if target.nil?
|
|
76
|
-
|
|
77
|
-
target.to_s == head[:aggregate].to_s
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Whether `head` has at least one declared where/order_by/offset/limit
|
|
81
|
-
# (or, for the sole many-side head only, a declared `authorize ...,
|
|
82
|
-
# tenant:` — `on:` doesn't extend to `tenant:`, a deliberate ADR 0055
|
|
83
|
-
# scope limit) targeting it.
|
|
84
|
-
def read_model_option_targets_head?(read_model, head, many_heads)
|
|
85
|
-
Array(read_model[:wheres]).any? { |w| option_target_matches_head?(w[:target], head, many_heads) } ||
|
|
86
|
-
(read_model[:order_by] && option_target_matches_head?(read_model[:order_by][:target], head, many_heads)) ||
|
|
87
|
-
(read_model[:limit] && option_target_matches_head?(read_model[:limit][:target], head, many_heads)) ||
|
|
88
|
-
(read_model[:offset] && option_target_matches_head?(read_model[:offset][:target], head, many_heads)) ||
|
|
89
|
-
(read_model.dig(:authorization, :tenant) && many_heads.size == 1 && many_heads.first[:as].to_s == head[:as].to_s)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Every many-side head this read model declares options for, in
|
|
93
|
-
# aggregate_heads' own declared order (stable regardless of where in the
|
|
94
|
-
# block a `where`/`order_by`/`on:` appears) — one entry when there's a
|
|
95
|
-
# single many-side head (the pre-ADR-0055 shape), more than one only when
|
|
96
|
-
# `on:` targets several.
|
|
97
|
-
def read_model_filtered_heads(read_model)
|
|
98
|
-
many_heads = read_model_many_heads(read_model)
|
|
99
|
-
many_heads.select { |head| read_model_option_targets_head?(read_model, head, many_heads) }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# One targeted head's own where/order_by/offset/limit, pulled out of the
|
|
103
|
-
# read model's full declared set. `authorization` carries `read_model`'s
|
|
104
|
-
# own declared authorization only for the sole many-side head (`on:`
|
|
105
|
-
# doesn't extend to `tenant:`), nil for every other targeted head.
|
|
106
|
-
def read_model_head_query(read_model, head, many_heads)
|
|
107
|
-
{
|
|
108
|
-
wheres: Array(read_model[:wheres]).select { |w| option_target_matches_head?(w[:target], head, many_heads) },
|
|
109
|
-
order_by: read_model[:order_by] && option_target_matches_head?(read_model[:order_by][:target], head, many_heads) ? read_model[:order_by] : nil,
|
|
110
|
-
limit: read_model[:limit] && option_target_matches_head?(read_model[:limit][:target], head, many_heads) ? read_model[:limit] : nil,
|
|
111
|
-
offset: read_model[:offset] && option_target_matches_head?(read_model[:offset][:target], head, many_heads) ? read_model[:offset] : nil,
|
|
112
|
-
authorization: many_heads.size == 1 && many_heads.first[:as].to_s == head[:as].to_s ? read_model[:authorization] : nil,
|
|
113
|
-
}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Checks every targeted head's own where/order_by/limit for generability
|
|
117
|
-
# against ITS aggregate (not the read model's root); nil means clean.
|
|
118
|
-
def read_model_options_content_skip_reason(read_model, aggregates_by_name)
|
|
119
|
-
many_heads = read_model_many_heads(read_model)
|
|
120
|
-
|
|
121
|
-
read_model_filtered_heads(read_model).each do |head|
|
|
122
|
-
aggregate = aggregates_by_name[head[:aggregate]]
|
|
123
|
-
return entity_head_skip_reason(head[:aggregate], "filtered head", "filter, order or authorize") unless aggregate
|
|
124
|
-
|
|
125
|
-
value_objects_by_name = aggregate[:value_objects].to_h { |vo| [vo[:name], vo] }
|
|
126
|
-
head_query = read_model_head_query(read_model, head, many_heads)
|
|
127
|
-
head_label = "head #{head[:aggregate]} (as #{head[:as]})"
|
|
128
|
-
|
|
129
|
-
head_query[:wheres].each do |where|
|
|
130
|
-
field = where[:field].to_s
|
|
131
|
-
hop = query_hop_plan(aggregate, field, aggregates_by_name)
|
|
132
|
-
if hop.nil? && field.include?("/")
|
|
133
|
-
return skip("reference_hop_where", "#{head_label}'s own where clause on #{field.inspect} hops through a " \
|
|
134
|
-
"reference this generator can't resolve yet (more than one hop, the head isn't a real " \
|
|
135
|
-
"reference attribute, or the target aggregate isn't declared in this domain) — not generated yet")
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
if hop
|
|
139
|
-
target_value_objects_by_name = hop[:target][:value_objects].to_h { |vo| [vo[:name], vo] }
|
|
140
|
-
reason = query_where_skip_reason(where.merge(field: hop[:inner_field]), hop[:target], target_value_objects_by_name)
|
|
141
|
-
return reskip(reason, "#{head_label}'s own hop through #{hop[:via_field]} to " \
|
|
142
|
-
"#{hop[:target_aggregate]}'s own #{reason}") if reason
|
|
143
|
-
next
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
reason = query_where_skip_reason(where, aggregate, value_objects_by_name)
|
|
147
|
-
return reskip(reason, "#{head_label}'s own #{reason}") if reason
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
auth_reason = declared_authorization_skip_reason(head_query[:authorization], aggregate, value_objects_by_name)
|
|
151
|
-
return auth_reason if auth_reason
|
|
152
|
-
|
|
153
|
-
order_reason = declared_order_by_skip_reason(head_query[:order_by], aggregate, value_objects_by_name)
|
|
154
|
-
return order_reason if order_reason
|
|
155
|
-
|
|
156
|
-
offset_reason = declared_offset_skip_reason(head_query[:offset])
|
|
157
|
-
return offset_reason if offset_reason
|
|
158
|
-
|
|
159
|
-
limit_reason = declared_limit_skip_reason(head_query[:limit])
|
|
160
|
-
return limit_reason if limit_reason
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
nil
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# Every reduction beyond `count` (which needs no field), each mapped to whether it admits
|
|
167
|
-
# only Integer (not Float) or a boolean field — mirrors
|
|
168
|
-
# Runtime::ReadModelInterpreter::INTEGER_ONLY_REDUCTIONS/BOOLEAN_REDUCTIONS (ADR 0078).
|
|
169
|
-
AGGREGATION_FIELD_KEYS = %i[median_field sum_field avg_field min_field max_field percentile_field
|
|
170
|
-
any_field all_field].freeze
|
|
171
|
-
INTEGER_ONLY_AGGREGATION_FIELDS = %i[sum_field avg_field].freeze
|
|
172
|
-
BOOLEAN_AGGREGATION_FIELDS = %i[any_field all_field].freeze
|
|
173
|
-
|
|
174
|
-
# A reduction field's own eligibility, checked after where/order_by/limit since a
|
|
175
|
-
# reduction only reduces the already-filtered row set.
|
|
176
|
-
def aggregation_skip_reason(read_model, aggregates_by_name)
|
|
177
|
-
field_key = AGGREGATION_FIELD_KEYS.find { |key| read_model[key] }
|
|
178
|
-
return nil unless field_key
|
|
179
|
-
|
|
180
|
-
target = read_model[:aggregate_heads].find { |head| head[:many] }
|
|
181
|
-
aggregate = aggregates_by_name[target[:aggregate]]
|
|
182
|
-
word = field_key.to_s.delete_suffix("_field")
|
|
183
|
-
return entity_head_skip_reason(target[:aggregate], "#{word} target", "reduce") unless aggregate
|
|
184
|
-
|
|
185
|
-
value_objects_by_name = aggregate[:value_objects].to_h { |vo| [vo[:name], vo] }
|
|
186
|
-
field = read_model[field_key].to_s
|
|
187
|
-
kind = query_field_kind(aggregate, field, value_objects_by_name)
|
|
188
|
-
return skip(field_key.to_s, "#{word} names #{field.inspect}, but #{target[:aggregate]} declares no such attribute — not generated yet") if kind == :unknown
|
|
189
|
-
|
|
190
|
-
if BOOLEAN_AGGREGATION_FIELDS.include?(field_key)
|
|
191
|
-
return nil if query_field_boolean?(aggregate, field, value_objects_by_name)
|
|
192
|
-
|
|
193
|
-
return skip(field_key.to_s, "#{word} names #{field.inspect} on #{target[:aggregate]}, which is not boolean — #{word} needs a " \
|
|
194
|
-
"true/false field — not generated yet")
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
return skip(field_key.to_s, "#{word} names #{field.inspect} on #{target[:aggregate]}, which is not numeric — #{word} needs a numeric " \
|
|
198
|
-
"field (a bare number, or a value object carrying one) — not generated yet") unless kind == :number
|
|
199
|
-
|
|
200
|
-
if INTEGER_ONLY_AGGREGATION_FIELDS.include?(field_key)
|
|
201
|
-
return nil if query_field_numeric_type(aggregate, field, value_objects_by_name) == "Integer"
|
|
202
|
-
|
|
203
|
-
return skip(field_key.to_s, "#{word} names #{field.inspect} on #{target[:aggregate]}, which is a Float field — #{word} admits " \
|
|
204
|
-
"only an Integer field (summing/averaging Floats cannot be made to agree, byte for byte, between " \
|
|
205
|
-
"Ruby and Rust) — not generated yet")
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
nil
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Whether a field (or dotted path) reduces specifically to a `TrueClass`/`FalseClass` —
|
|
212
|
-
# `any`/`all` need this, distinct from `query_field_kind`'s coarser `:other` bucket.
|
|
213
|
-
def query_field_boolean?(aggregate, field, value_objects_by_name)
|
|
214
|
-
segments = field.to_s.split(".")
|
|
215
|
-
attr = aggregate[:attributes].find { |a| a[:name].to_s == segments.first }
|
|
216
|
-
return false unless attr && !attr[:list]
|
|
217
|
-
|
|
218
|
-
query_type_boolean?(attr[:type].to_s, segments[1..] || [], value_objects_by_name)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def query_type_boolean?(type_name, segments, value_objects_by_name)
|
|
222
|
-
if segments.empty?
|
|
223
|
-
return true if %w[TrueClass FalseClass].include?(type_name)
|
|
224
|
-
return false if reference_type?(type_name)
|
|
225
|
-
|
|
226
|
-
vo = value_objects_by_name[type_name]
|
|
227
|
-
return false unless vo
|
|
228
|
-
return query_type_boolean?(vo[:attributes].first[:type].to_s, [], value_objects_by_name) if vo[:attributes].size == 1
|
|
229
|
-
|
|
230
|
-
false
|
|
231
|
-
else
|
|
232
|
-
vo = value_objects_by_name[type_name]
|
|
233
|
-
return false unless vo
|
|
234
|
-
|
|
235
|
-
member = vo[:attributes].find { |a| a[:name].to_s == segments.first }
|
|
236
|
-
return false if member.nil? || member[:list]
|
|
237
|
-
|
|
238
|
-
query_type_boolean?(member[:type].to_s, segments[1..] || [], value_objects_by_name)
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# The winning member's own type name for a numeric field ("the sole numeric member wins",
|
|
243
|
-
# mirroring `query_vo_collapse_kind`) — "Integer" or "Float" distinctly, where
|
|
244
|
-
# `query_field_kind`'s own `:number` collapses both; `nil` for anything else. `sum`/`avg`
|
|
245
|
-
# need the distinction (ADR 0078).
|
|
246
|
-
def query_field_numeric_type(aggregate, field, value_objects_by_name)
|
|
247
|
-
segments = field.to_s.split(".")
|
|
248
|
-
attr = aggregate[:attributes].find { |a| a[:name].to_s == segments.first }
|
|
249
|
-
return nil unless attr && !attr[:list]
|
|
250
|
-
|
|
251
|
-
query_type_numeric_type(attr[:type].to_s, segments[1..] || [], value_objects_by_name)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def query_type_numeric_type(type_name, segments, value_objects_by_name)
|
|
255
|
-
if segments.empty?
|
|
256
|
-
return type_name if %w[Integer Float].include?(type_name)
|
|
257
|
-
return nil if reference_type?(type_name)
|
|
258
|
-
|
|
259
|
-
vo = value_objects_by_name[type_name]
|
|
260
|
-
return nil unless vo
|
|
261
|
-
|
|
262
|
-
numeric_member = vo[:attributes].find { |a| %w[Integer Float].include?(a[:type].to_s) }
|
|
263
|
-
return numeric_member[:type].to_s if numeric_member
|
|
264
|
-
return query_type_numeric_type(vo[:attributes].first[:type].to_s, [], value_objects_by_name) if vo[:attributes].size == 1
|
|
265
|
-
|
|
266
|
-
nil
|
|
267
|
-
else
|
|
268
|
-
vo = value_objects_by_name[type_name]
|
|
269
|
-
return nil unless vo
|
|
270
|
-
|
|
271
|
-
member = vo[:attributes].find { |a| a[:name].to_s == segments.first }
|
|
272
|
-
return nil if member.nil? || member[:list]
|
|
273
|
-
|
|
274
|
-
query_type_numeric_type(member[:type].to_s, segments[1..] || [], value_objects_by_name)
|
|
275
|
-
end
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Nested entity names across every aggregate (recursively) — an
|
|
279
|
-
# `include` naming one of these is generated as an empty head, since
|
|
280
|
-
# entities have no rows of their own to project.
|
|
281
|
-
def nested_entity_names(aggregates_by_name)
|
|
282
|
-
collect = lambda do |owners|
|
|
283
|
-
owners.flat_map { |owner| Array(owner[:entities]).flat_map { |entity| [entity[:name].to_s, *collect.call([entity])] } }
|
|
284
|
-
end
|
|
285
|
-
collect.call(aggregates_by_name.values)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def entity_head_skip_reason(aggregate_name, role, purpose)
|
|
289
|
-
skip("include_entity_head", "includes #{aggregate_name}, a nested entity, as the #{role} — an entity has no rows of its own " \
|
|
290
|
-
"(ReadModelInterpreter#records reads it as empty), so there is nothing to #{purpose} — not generated yet")
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def read_model_head_skip_reason(head, aggregates_by_name, unsupported_names)
|
|
294
|
-
target = aggregates_by_name[head[:aggregate]]
|
|
295
|
-
unless target
|
|
296
|
-
return nil if nested_entity_names(aggregates_by_name).include?(head[:aggregate].to_s)
|
|
297
|
-
|
|
298
|
-
return skip("include_undeclared_aggregate", "includes #{head[:aggregate]}, which this domain never declares")
|
|
299
|
-
end
|
|
300
|
-
return skip("include_unsupported_aggregate", "includes #{head[:aggregate]}, which this generator couldn't itself generate " \
|
|
301
|
-
"(unsupported attribute type — see this domain's own aggregate-level manifest entry)") if unsupported_names.include?(head[:aggregate])
|
|
302
|
-
|
|
303
|
-
nil
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# `group_by` eligibility, narrowed to the one shape the corpus declares:
|
|
307
|
-
# a single rootless head, group_by alone (build time already guarantees
|
|
308
|
-
# at most one many-side head whenever group_by is declared at all).
|
|
309
|
-
def group_by_skip_reason(read_model, aggregates_by_name, unsupported_names)
|
|
310
|
-
heads = read_model[:aggregate_heads]
|
|
311
|
-
return skip("group_by", "declares group_by across #{heads.size} aggregate heads — not generated yet (only a single, rootless head is)") if heads.size != 1
|
|
312
|
-
return skip("group_by", "declares group_by on a NON-rootless read model (reference_to #{read_model[:reference_target]}) — not generated yet") unless read_model[:reference_target].nil?
|
|
313
|
-
if read_model[:count] || AGGREGATION_FIELD_KEYS.any? { |key| read_model[key] }
|
|
314
|
-
return skip("group_by", "declares group_by alongside a reduction — not generated yet")
|
|
315
|
-
end
|
|
316
|
-
return skip("group_by", "declares group_by alongside where/order_by/limit/offset — not generated yet") if Array(read_model[:wheres]).any? || read_model[:order_by] || read_model[:limit] || read_model[:offset]
|
|
317
|
-
return skip("group_by", "declares group_by with an authorize policy — not generated yet") if read_model[:authorization]
|
|
318
|
-
|
|
319
|
-
head = heads.first
|
|
320
|
-
reason = read_model_head_skip_reason(head, aggregates_by_name, unsupported_names)
|
|
321
|
-
return reason if reason
|
|
322
|
-
|
|
323
|
-
aggregate = aggregates_by_name[head[:aggregate]]
|
|
324
|
-
return entity_head_skip_reason(head[:aggregate], "group_by head", "group") unless aggregate
|
|
325
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
|
|
326
|
-
Array(read_model[:group_by]).each do |row|
|
|
327
|
-
field_s = row[:field].to_s
|
|
328
|
-
next if aggregate[:attributes].any? { |a| a[:name].to_s == field_s }
|
|
329
|
-
next if field_s == lifecycle_field
|
|
330
|
-
|
|
331
|
-
return skip("group_by", "group_by names #{field_s.inspect}, but #{aggregate[:name]} declares no such attribute — not generated yet")
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
nil
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
# Compiles already-confirmed-generable hop wheres into the wire shape
|
|
338
|
-
# ReferenceHopCondition expects.
|
|
339
|
-
def read_model_hop_conditions(domain_name, hop_wheres, aggregate, aggregates_by_name)
|
|
340
|
-
hop_wheres.map do |where|
|
|
341
|
-
hop = query_hop_plan(aggregate, where[:field].to_s, aggregates_by_name)
|
|
342
|
-
raw_value = where[:value].to_s
|
|
343
|
-
symbol = raw_value.start_with?(":")
|
|
344
|
-
{
|
|
345
|
-
via_field: hop[:via_field],
|
|
346
|
-
target_aggregate: "#{domain_name}::#{hop[:target_aggregate]}",
|
|
347
|
-
through: hop[:through].map { |step| { via_field: step[:via_field], target_aggregate: "#{domain_name}::#{step[:target_aggregate]}" } },
|
|
348
|
-
inner_field: hop[:inner_field],
|
|
349
|
-
op: where[:op].to_s,
|
|
350
|
-
arg: symbol ? raw_value.delete_prefix(":") : nil,
|
|
351
|
-
literal: symbol ? nil : Hecks::Literal.read(raw_value),
|
|
352
|
-
}
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def emit_reference_hop_condition(hop)
|
|
357
|
-
comparator_expr = "crate::kernel::query_comparators::QueryComparator::#{query_comparator_variant(hop[:op])}"
|
|
358
|
-
through = Array(hop[:through]).map do |step|
|
|
359
|
-
"crate::kernel::read_model::HopStep { via_field: #{step[:via_field].inspect}, target_aggregate: #{step[:target_aggregate].inspect} }"
|
|
360
|
-
end
|
|
361
|
-
"crate::kernel::read_model::ReferenceHopCondition { via_field: #{hop[:via_field].inspect}, " \
|
|
362
|
-
"target_aggregate: #{hop[:target_aggregate].inspect}, through: &[#{through.join(', ')}], " \
|
|
363
|
-
"inner_field: #{hop[:inner_field].inspect}, " \
|
|
364
|
-
"inner_comparator: #{comparator_expr}, inner_value: #{emit_query_condition_value(hop)} },"
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
# Baked in once at codegen time — the compiled kernel has no runtime
|
|
368
|
-
# attribute-type reflection. Includes every reference field regardless
|
|
369
|
-
# of whether its target is a head here; the matcher simply won't match.
|
|
370
|
-
def read_model_reference_fields(head_aggregate)
|
|
371
|
-
head_aggregate[:attributes].filter_map do |attr|
|
|
372
|
-
target = reference_target(attr[:type])
|
|
373
|
-
next unless target
|
|
374
|
-
|
|
375
|
-
{ target: target, field: attr[:name] }
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def emit_reference_field(domain_name, reference_field)
|
|
380
|
-
qualified_target = "#{domain_name}::#{reference_field[:target]}"
|
|
381
|
-
"crate::kernel::read_model::ReferenceField { target_aggregate: #{qualified_target.inspect}, " \
|
|
382
|
-
"field: #{reference_field[:field].to_s.inspect} }"
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
# Compiles one declared `include`. `is_root` is precomputed here rather
|
|
386
|
-
# than re-compared at runtime; the root has no reference_fields since
|
|
387
|
-
# it's fetched directly by id, never matched by reference.
|
|
388
|
-
def emit_read_model_head(domain_name, head, is_root, aggregates_by_name)
|
|
389
|
-
head_aggregate = aggregates_by_name[head[:aggregate]]
|
|
390
|
-
reference_fields = is_root || head_aggregate.nil? ? [] : read_model_reference_fields(head_aggregate)
|
|
391
|
-
reference_fields_expr = reference_fields.map { |rf| emit_reference_field(domain_name, rf) }.join(", ")
|
|
392
|
-
qualified_aggregate = "#{domain_name}::#{head[:aggregate]}"
|
|
393
|
-
|
|
394
|
-
"crate::kernel::read_model::ReadModelHead { aggregate: #{qualified_aggregate.inspect}, " \
|
|
395
|
-
"as_name: #{head[:as].to_s.inspect}, many: #{head[:many] ? 'true' : 'false'}, " \
|
|
396
|
-
"is_root: #{is_root ? 'true' : 'false'}, reference_fields: &[#{reference_fields_expr}] }"
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# Compiles a declared order_by into Rust's ReadModelOrderBy; `descending`
|
|
400
|
-
# collapses the desc/asc string check once, at codegen time.
|
|
401
|
-
def emit_read_model_order_by(order_by, null_semantics = nil)
|
|
402
|
-
descending = order_by[:direction].to_s == "desc" ? "true" : "false"
|
|
403
|
-
"crate::kernel::read_model::ReadModelOrderBy { field: #{order_by[:field].to_s.inspect}, descending: #{descending}, " \
|
|
404
|
-
"nulls: #{null_semantics_variant(null_semantics)} }"
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
# Compiles a declared limit into a Literal or an Arg reference; a
|
|
408
|
-
# non-Arg value is already confirmed to be a real integer literal.
|
|
409
|
-
def emit_read_model_limit(limit)
|
|
410
|
-
raw = limit[:value].to_s
|
|
411
|
-
return "crate::kernel::read_model::ReadModelLimit::Arg(#{raw.delete_prefix(':').inspect})" if raw.start_with?(":")
|
|
412
|
-
|
|
413
|
-
"crate::kernel::read_model::ReadModelLimit::Literal(#{raw.to_i})"
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
# ReadModelOffset is a type alias of ReadModelLimit, so this reuses
|
|
417
|
-
# emit_read_model_limit and swaps only the spelled type name.
|
|
418
|
-
def emit_read_model_offset(offset) = emit_read_model_limit(offset).sub("read_model::ReadModelLimit::", "read_model::ReadModelOffset::")
|
|
419
|
-
|
|
420
|
-
# One targeted head's own where/order_by/offset/limit, compiled to plain
|
|
421
|
-
# data for `emit_filtered_head`. Hop wheres need a different wire shape
|
|
422
|
-
# (ReferenceHopCondition) than local ones, so they're split out here
|
|
423
|
-
# rather than reused wholesale; every clause has already been confirmed
|
|
424
|
-
# resolvable or generable by read_model_options_content_skip_reason.
|
|
425
|
-
def filtered_head_def(domain_name, read_model, head, many_heads, aggregates_by_name)
|
|
426
|
-
aggregate = aggregates_by_name[head[:aggregate]]
|
|
427
|
-
head_query = read_model_head_query(read_model, head, many_heads)
|
|
428
|
-
local_wheres, hop_wheres = head_query[:wheres].partition { |w| query_hop_plan(aggregate, w[:field].to_s, aggregates_by_name).nil? }
|
|
429
|
-
conditions_source = head_query.merge(wheres: local_wheres)
|
|
430
|
-
|
|
431
|
-
{
|
|
432
|
-
as_name: head[:as].to_s,
|
|
433
|
-
conditions: head_query[:authorization] ? query_conditions_with_authorization(conditions_source) : query_conditions(conditions_source),
|
|
434
|
-
reference_hop_conditions: read_model_hop_conditions(domain_name, hop_wheres, aggregate, aggregates_by_name),
|
|
435
|
-
order_by: head_query[:order_by] ? emit_read_model_order_by(head_query[:order_by], read_model[:null_semantics]) : nil,
|
|
436
|
-
offset: head_query[:offset] ? emit_read_model_offset(head_query[:offset]) : nil,
|
|
437
|
-
limit: head_query[:limit] ? emit_read_model_limit(head_query[:limit]) : nil,
|
|
438
|
-
}
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
# Compiles a whole declared read model to plain data for
|
|
442
|
-
# `emit_read_model_def`. `verb` uses the read model's declared name
|
|
443
|
-
# ("Domain.Name"), the same convention queries.rb picks for a named
|
|
444
|
-
# query — `kernel/cli.rs` tells the two shapes apart by the "::" that
|
|
445
|
-
# only a Domain::Aggregate.Name query verb has before the first ".".
|
|
446
|
-
#
|
|
447
|
-
# `filtered_heads` holds one entry per head `read_model_filtered_heads`
|
|
448
|
-
# names; skip_reason above already refused anything whose content wasn't
|
|
449
|
-
# generable. `authorization` (the `ReadModelDef`-level tenant-arg check)
|
|
450
|
-
# only ever applies to a single many-side head — `on:` doesn't extend to
|
|
451
|
-
# `tenant:` (ADR 0055's own scope limit).
|
|
452
|
-
def read_model_def(domain_name, read_model, aggregates_by_name)
|
|
453
|
-
heads = read_model[:aggregate_heads].map do |head|
|
|
454
|
-
is_root = head[:aggregate].to_s == read_model[:reference_target].to_s
|
|
455
|
-
emit_read_model_head(domain_name, head, is_root, aggregates_by_name)
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
many_heads = read_model_many_heads(read_model)
|
|
459
|
-
filtered_heads = read_model_filtered_heads(read_model).map { |head| filtered_head_def(domain_name, read_model, head, many_heads, aggregates_by_name) }
|
|
460
|
-
sole_head_as = many_heads.size == 1 ? many_heads.first[:as] : nil
|
|
461
|
-
group_by_fn_name, group_by_fn_body = read_model_group_by_fn(read_model, aggregates_by_name)
|
|
462
|
-
|
|
463
|
-
{
|
|
464
|
-
verb: "#{domain_name}.#{read_model[:name]}",
|
|
465
|
-
reference_name: read_model[:reference_name] ? read_model[:reference_name].to_s : nil,
|
|
466
|
-
heads: heads,
|
|
467
|
-
filtered_heads: filtered_heads,
|
|
468
|
-
authorization: sole_head_as ? emit_query_authorization(read_model[:name], read_model[:authorization]) : nil,
|
|
469
|
-
group_by_fn: group_by_fn_name,
|
|
470
|
-
group_by_fn_body: group_by_fn_body,
|
|
471
|
-
count: !!read_model[:count],
|
|
472
|
-
median_field: read_model[:median_field] ? read_model[:median_field].to_s : nil,
|
|
473
|
-
sum_field: read_model[:sum_field] ? read_model[:sum_field].to_s : nil,
|
|
474
|
-
avg_field: read_model[:avg_field] ? read_model[:avg_field].to_s : nil,
|
|
475
|
-
min_field: read_model[:min_field] ? read_model[:min_field].to_s : nil,
|
|
476
|
-
max_field: read_model[:max_field] ? read_model[:max_field].to_s : nil,
|
|
477
|
-
percentile_field: read_model[:percentile_field] ? read_model[:percentile_field].to_s : nil,
|
|
478
|
-
percentile_at: read_model[:percentile_field] ? read_model[:percentile_at] : nil,
|
|
479
|
-
any_field: read_model[:any_field] ? read_model[:any_field].to_s : nil,
|
|
480
|
-
all_field: read_model[:all_field] ? read_model[:all_field].to_s : nil,
|
|
481
|
-
}
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# The generated group_by transform fn's name and body, or `[nil, nil]` when this read model
|
|
485
|
-
# declares no `group_by` at all. Split out of `read_model_def` to keep its own complexity down.
|
|
486
|
-
def read_model_group_by_fn(read_model, aggregates_by_name)
|
|
487
|
-
group_by_fields = Array(read_model[:group_by]).map { |row| row[:field].to_s }
|
|
488
|
-
return [nil, nil] if group_by_fields.empty?
|
|
489
|
-
|
|
490
|
-
fn_name = "group_by_#{read_model[:name].to_s.downcase}"
|
|
491
|
-
root_aggregate = aggregates_by_name[read_model[:aggregate_heads].first[:aggregate]]
|
|
492
|
-
[fn_name, emit_group_by_transform(fn_name, read_model[:name].to_s, root_aggregate, group_by_fields)]
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
# Generates the group_by transform fn by name, since the kernel's own
|
|
496
|
-
# generic `run` only ever holds already-serialized Json — which value
|
|
497
|
-
# object unwraps to a bare scalar is a type-level fact only codegen has.
|
|
498
|
-
#
|
|
499
|
-
# It re-attaches `id`, keeps only real declared attributes plus any
|
|
500
|
-
# `projects` field while excluding Rust-only synthetic fields (ADR 0049),
|
|
501
|
-
# and recursively unwraps single-attribute value objects. Actual grouping
|
|
502
|
-
# is `kernel::read_model::nest`, which refuses a second row per leaf
|
|
503
|
-
# unless `group_by_leaf_check` found the key path covers the identity.
|
|
504
|
-
def emit_group_by_transform(fn_name, read_model_name, aggregate, group_by_fields)
|
|
505
|
-
value_objects_by_name = aggregate[:value_objects].to_h { |vo| [vo[:name].to_s, vo] }
|
|
506
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
|
|
507
|
-
fields = aggregate[:attributes] + Projector.projected_field_pseudo_attributes(aggregate)
|
|
508
|
-
kept_keys = fields.map { |a| a[:name].to_s } + ["id"] + (lifecycle_field ? [lifecycle_field] : [])
|
|
509
|
-
keep_cond = kept_keys.map { |k| "k == #{k.inspect}" }.join(" || ")
|
|
510
|
-
|
|
511
|
-
arms = fields.map do |a|
|
|
512
|
-
unwrapped = unwrap_json_expr("v", a[:type].to_s, a[:list], aggregate, value_objects_by_name)
|
|
513
|
-
"#{a[:name].to_s.inspect} => #{unwrapped},"
|
|
514
|
-
end.join("\n ")
|
|
515
|
-
|
|
516
|
-
fields_literal = "&[#{group_by_fields.map(&:inspect).join(', ')}]"
|
|
517
|
-
leaf_check = group_by_leaf_check(read_model_name, aggregate, group_by_fields)
|
|
518
|
-
|
|
519
|
-
<<~RUST.rstrip
|
|
520
|
-
pub fn #{fn_name}(rows: Vec<(String, crate::kernel::Json)>) -> Result<crate::kernel::Json, crate::kernel::Refusal> {
|
|
521
|
-
let unwrapped: Vec<crate::kernel::Json> = rows
|
|
522
|
-
.into_iter()
|
|
523
|
-
.map(|(id, record)| {
|
|
524
|
-
let wrapped = crate::kernel::repository::row_json(id, record);
|
|
525
|
-
match wrapped {
|
|
526
|
-
crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(
|
|
527
|
-
fields
|
|
528
|
-
.into_iter()
|
|
529
|
-
.filter(|(k, _)| #{keep_cond})
|
|
530
|
-
.map(|(k, v)| {
|
|
531
|
-
let new_v = match k.as_str() {
|
|
532
|
-
#{arms}
|
|
533
|
-
_ => v,
|
|
534
|
-
};
|
|
535
|
-
(k, new_v)
|
|
536
|
-
})
|
|
537
|
-
.collect(),
|
|
538
|
-
),
|
|
539
|
-
other => other,
|
|
540
|
-
}
|
|
541
|
-
})
|
|
542
|
-
.collect();
|
|
543
|
-
crate::kernel::read_model::nest(unwrapped, #{fields_literal}, #{leaf_check})
|
|
544
|
-
}
|
|
545
|
-
RUST
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
# A key path naming every identity field of the grouped aggregate can't
|
|
549
|
-
# collide, so its leaves go unchecked; any other refuses a second row
|
|
550
|
-
# per leaf (ADR 0061). `identified_by` in the IR holds identity paths.
|
|
551
|
-
def group_by_leaf_check(read_model_name, aggregate, group_by_fields)
|
|
552
|
-
identity = Array(aggregate[:identified_by]).map { |path| path.to_s.split(".").first }.uniq
|
|
553
|
-
return "crate::kernel::read_model::LeafCheck::IdentityCovered" if identity.any? && (identity - group_by_fields).empty?
|
|
554
|
-
|
|
555
|
-
"crate::kernel::read_model::LeafCheck::RefuseCollision(#{read_model_name.inspect})"
|
|
556
|
-
end
|
|
557
|
-
|
|
558
|
-
# Ports Value.materialize_unwrapped: a single-attribute value object
|
|
559
|
-
# unwraps to its bare field; a multi-attribute one or an entity keeps
|
|
560
|
-
# its object shape but recurses into each field; scalars and lists
|
|
561
|
-
# (mapped element-wise) pass through the same logic unchanged.
|
|
562
|
-
def unwrap_json_expr(expr, type_name, list, aggregate, value_objects_by_name)
|
|
563
|
-
if list
|
|
564
|
-
inner = unwrap_json_expr("item", type_name, false, aggregate, value_objects_by_name)
|
|
565
|
-
return "match #{expr} { crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| #{inner}).collect()), other => other }"
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
vo = value_objects_by_name[type_name]
|
|
569
|
-
entity = (aggregate[:entities] || []).find { |e| e[:name].to_s == type_name.to_s }
|
|
570
|
-
fields_meta = vo ? vo[:attributes] : (entity ? entity[:attributes] : nil)
|
|
571
|
-
return expr unless fields_meta
|
|
572
|
-
|
|
573
|
-
if vo && fields_meta.size == 1
|
|
574
|
-
field = fields_meta.first
|
|
575
|
-
unwrapped = unwrap_json_expr("field_value", field[:type].to_s, field[:list], aggregate, value_objects_by_name)
|
|
576
|
-
return "match #{expr} { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == #{field[:name].to_s.inspect}).map(|(_, field_value)| #{unwrapped}).unwrap_or(crate::kernel::Json::Null), other => other }"
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
inner_arms = fields_meta.map do |f|
|
|
580
|
-
unwrapped = unwrap_json_expr("v", f[:type].to_s, f[:list], aggregate, value_objects_by_name)
|
|
581
|
-
"#{f[:name].to_s.inspect} => #{unwrapped},"
|
|
582
|
-
end.join(" ")
|
|
583
|
-
"match #{expr} { crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(fields.into_iter().map(|(k, v)| { let new_v = match k.as_str() { #{inner_arms} _ => v }; (k, new_v) }).collect()), other => other }"
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
# One `FilteredHead` entry, always emitted as a single line — matching
|
|
587
|
-
# the codebase's own established idiom for every OTHER leaf struct
|
|
588
|
-
# (ReadModelHead, ReferenceField, ReferenceHopCondition, QueryCondition
|
|
589
|
-
# are all single-line too; only the top-level ReadModelDef/QueryDef get
|
|
590
|
-
# pretty multi-line formatting with their arrays broken across lines).
|
|
591
|
-
def emit_filtered_head(filtered_head_def)
|
|
592
|
-
conditions = filtered_head_def[:conditions].map { |c| emit_query_condition(c) }.join(" ")
|
|
593
|
-
reference_hop_conditions = filtered_head_def[:reference_hop_conditions].map { |h| emit_reference_hop_condition(h) }.join(" ")
|
|
594
|
-
order_by = filtered_head_def[:order_by] ? "Some(#{filtered_head_def[:order_by]})" : "None"
|
|
595
|
-
offset = filtered_head_def[:offset] ? "Some(#{filtered_head_def[:offset]})" : "None"
|
|
596
|
-
limit = filtered_head_def[:limit] ? "Some(#{filtered_head_def[:limit]})" : "None"
|
|
597
|
-
|
|
598
|
-
"crate::kernel::read_model::FilteredHead { as_name: #{filtered_head_def[:as_name].inspect}, " \
|
|
599
|
-
"conditions: &[#{conditions}], reference_hop_conditions: &[#{reference_hop_conditions}], " \
|
|
600
|
-
"order_by: #{order_by}, offset: #{offset}, limit: #{limit} },"
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
def emit_read_model_def(read_model_def)
|
|
604
|
-
heads = read_model_def[:heads].map { |head| " #{head}," }.join("\n")
|
|
605
|
-
filtered_heads = read_model_def[:filtered_heads].map { |fh| " #{emit_filtered_head(fh)}" }.join("\n")
|
|
606
|
-
authorization = read_model_def[:authorization] ? "Some(#{read_model_def[:authorization]})" : "None"
|
|
607
|
-
reference_name = read_model_def[:reference_name] ? "Some(#{read_model_def[:reference_name].inspect})" : "None"
|
|
608
|
-
group_by = read_model_def[:group_by_fn] ? "Some(#{read_model_def[:group_by_fn]})" : "None"
|
|
609
|
-
count = read_model_def[:count] ? "true" : "false"
|
|
610
|
-
median_field = read_model_def[:median_field] ? "Some(#{read_model_def[:median_field].inspect})" : "None"
|
|
611
|
-
sum_field = read_model_def[:sum_field] ? "Some(#{read_model_def[:sum_field].inspect})" : "None"
|
|
612
|
-
avg_field = read_model_def[:avg_field] ? "Some(#{read_model_def[:avg_field].inspect})" : "None"
|
|
613
|
-
min_field = read_model_def[:min_field] ? "Some(#{read_model_def[:min_field].inspect})" : "None"
|
|
614
|
-
max_field = read_model_def[:max_field] ? "Some(#{read_model_def[:max_field].inspect})" : "None"
|
|
615
|
-
percentile_field = read_model_def[:percentile_field] ? "Some(#{read_model_def[:percentile_field].inspect})" : "None"
|
|
616
|
-
percentile_at = read_model_def[:percentile_at] ? "Some(#{Float(read_model_def[:percentile_at])}_f64)" : "None"
|
|
617
|
-
any_field = read_model_def[:any_field] ? "Some(#{read_model_def[:any_field].inspect})" : "None"
|
|
618
|
-
all_field = read_model_def[:all_field] ? "Some(#{read_model_def[:all_field].inspect})" : "None"
|
|
619
|
-
|
|
620
|
-
<<~RUST.rstrip
|
|
621
|
-
crate::kernel::read_model::ReadModelDef {
|
|
622
|
-
verb: #{read_model_def[:verb].inspect},
|
|
623
|
-
reference_name: #{reference_name},
|
|
624
|
-
heads: &[
|
|
625
|
-
#{heads}
|
|
626
|
-
],
|
|
627
|
-
filtered_heads: &[
|
|
628
|
-
#{filtered_heads}
|
|
629
|
-
],
|
|
630
|
-
authorization: #{authorization},
|
|
631
|
-
group_by: #{group_by},
|
|
632
|
-
count: #{count},
|
|
633
|
-
median_field: #{median_field},
|
|
634
|
-
sum_field: #{sum_field},
|
|
635
|
-
avg_field: #{avg_field},
|
|
636
|
-
min_field: #{min_field},
|
|
637
|
-
max_field: #{max_field},
|
|
638
|
-
percentile_field: #{percentile_field},
|
|
639
|
-
percentile_at: #{percentile_at},
|
|
640
|
-
any_field: #{any_field},
|
|
641
|
-
all_field: #{all_field},
|
|
642
|
-
},
|
|
643
|
-
RUST
|
|
644
|
-
end
|
|
645
|
-
|
|
646
|
-
# Must dedent-match `rust/src/exemplar/read_models.rs`'s own
|
|
647
|
-
# TMPL:read_model_table placeholder row exactly — Exemplar.render
|
|
648
|
-
# substitutes by literal substring.
|
|
649
|
-
READ_MODEL_TABLE_ROW_PLACEHOLDER = <<~RUST.rstrip
|
|
650
|
-
crate::kernel::read_model::ReadModelDef {
|
|
651
|
-
verb: "tmpl_verb",
|
|
652
|
-
reference_name: Some("tmpl_reference_name"),
|
|
653
|
-
heads: &[
|
|
654
|
-
crate::kernel::read_model::ReadModelHead {
|
|
655
|
-
aggregate: "tmpl_aggregate",
|
|
656
|
-
as_name: "tmpl_as_name",
|
|
657
|
-
many: true,
|
|
658
|
-
is_root: false,
|
|
659
|
-
reference_fields: &[
|
|
660
|
-
crate::kernel::read_model::ReferenceField { target_aggregate: "tmpl_target_aggregate", field: "tmpl_field" },
|
|
661
|
-
],
|
|
662
|
-
},
|
|
663
|
-
],
|
|
664
|
-
filtered_heads: &[
|
|
665
|
-
crate::kernel::read_model::FilteredHead { as_name: "tmpl_as_name", conditions: &[crate::kernel::QueryCondition { field: "tmpl_field", comparator: crate::kernel::query_comparators::QueryComparator::Eq, value: crate::kernel::QueryConditionValue::Literal("tmpl_literal") },], reference_hop_conditions: &[crate::kernel::read_model::ReferenceHopCondition { via_field: "tmpl_via_field", target_aggregate: "tmpl_target_aggregate", through: &[crate::kernel::read_model::HopStep { via_field: "tmpl_via_field", target_aggregate: "tmpl_target_aggregate" }], inner_field: "tmpl_inner_field", inner_comparator: crate::kernel::query_comparators::QueryComparator::Eq, inner_value: crate::kernel::QueryConditionValue::Literal("tmpl_literal") },], order_by: Some(crate::kernel::read_model::ReadModelOrderBy { field: "tmpl_order_field", descending: true, nulls: crate::kernel::query_ordering::NullsMode::Last }), offset: Some(crate::kernel::read_model::ReadModelOffset::Literal(1)), limit: Some(crate::kernel::read_model::ReadModelLimit::Literal(5)) },
|
|
666
|
-
],
|
|
667
|
-
authorization: Some(crate::kernel::named_query::TenantAuth { query_name: "tmpl_query_name", tenant_field: "tmpl_tenant_field", policy: "tmpl_policy" }),
|
|
668
|
-
group_by: None,
|
|
669
|
-
count: false,
|
|
670
|
-
median_field: None,
|
|
671
|
-
sum_field: None,
|
|
672
|
-
avg_field: None,
|
|
673
|
-
min_field: None,
|
|
674
|
-
max_field: None,
|
|
675
|
-
percentile_field: None,
|
|
676
|
-
percentile_at: None,
|
|
677
|
-
any_field: None,
|
|
678
|
-
all_field: None,
|
|
679
|
-
},
|
|
680
|
-
RUST
|
|
681
|
-
|
|
682
|
-
# The read model table `kernel::read_model::run` walks at runtime — one
|
|
683
|
-
# row per read model that `read_model_skip_reason` lets through; a
|
|
684
|
-
# skipped read model simply has no row here.
|
|
685
|
-
def emit_read_model_table(read_model_defs)
|
|
686
|
-
rows = read_model_defs.map { |rmd| emit_read_model_def(rmd) }
|
|
687
|
-
Exemplar.render("read_model_table", READ_MODEL_TABLE_ROW_PLACEHOLDER => rows.join("\n"))
|
|
688
|
-
end
|
|
689
|
-
end
|
|
690
|
-
end
|