hecks 3.1.3 → 3.2.1
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/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- 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/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/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- 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_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- 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/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/auth.rs +25 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/host/src/web.rs +9 -5
- 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 +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +5 -23
- 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/queries.rb
DELETED
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
require_relative "skip_reason"
|
|
2
|
-
|
|
3
|
-
# Generates the Rust named-query table from the exported IR, with skip reasons.
|
|
4
|
-
module RustProjection
|
|
5
|
-
module Projector
|
|
6
|
-
module_function
|
|
7
|
-
|
|
8
|
-
# Named-query codegen: a declared `query` whose where clauses compare one aggregate's own
|
|
9
|
-
# fields becomes a `QueryDef` row, with its `order_by`/`offset`/`limit` carried alongside.
|
|
10
|
-
#
|
|
11
|
-
# `query_skip_reason` is the gate; a query it refuses gets no row, with the reason recorded.
|
|
12
|
-
# Refused: cursor/consistency/freshness/inspection/use_index, order_by on a field that is not
|
|
13
|
-
# a plain string or number, a limit/offset that is neither an integer literal nor a Symbol arg.
|
|
14
|
-
#
|
|
15
|
-
# Literal where values arrive through the exported IR as `value.to_s`, so an Integer and a
|
|
16
|
-
# String literal are indistinguishable. gt/gte/lt/lte therefore never take a literal, eq/ne
|
|
17
|
-
# take one only against a provably string-shaped field, and in/contains stringify anyway.
|
|
18
|
-
# A Symbol value resolves from the typed wire args at dispatch, so it is always safe.
|
|
19
|
-
COMPARATORS_NEEDING_NUMERIC_FIDELITY = %w[gt gte lt lte].freeze
|
|
20
|
-
COMPARATORS_EXEMPT_FROM_LITERAL_TYPING = %w[in contains].freeze
|
|
21
|
-
|
|
22
|
-
# The kind (`:string`, `:number`, `:other`, `:unknown`) a where/order_by `field` reduces to
|
|
23
|
-
# on `aggregate`, mirroring `Ports::Query::InMemory#comparable`. Nested value-object
|
|
24
|
-
# segments are walked; `:unknown` means the head is not one of the aggregate's own
|
|
25
|
-
# attributes, which is also how a reference hop is detected.
|
|
26
|
-
def query_field_kind(aggregate, field, value_objects_by_name)
|
|
27
|
-
segments = field.to_s.split(".")
|
|
28
|
-
head = segments.first
|
|
29
|
-
rest = segments[1..] || []
|
|
30
|
-
|
|
31
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field]
|
|
32
|
-
return rest.empty? ? :string : :unknown if lifecycle_field && lifecycle_field.to_s == head
|
|
33
|
-
|
|
34
|
-
attr = aggregate[:attributes].find { |a| a[:name].to_s == head }
|
|
35
|
-
return :unknown unless attr
|
|
36
|
-
# A `list_of` field compares as an array, never a scalar a literal could target.
|
|
37
|
-
return :other if attr[:list]
|
|
38
|
-
|
|
39
|
-
query_type_kind(attr[:type].to_s, rest, value_objects_by_name)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def query_type_kind(type_name, segments, value_objects_by_name)
|
|
43
|
-
return :unknown if reference_type?(type_name) && !segments.empty?
|
|
44
|
-
return :string if reference_type?(type_name)
|
|
45
|
-
|
|
46
|
-
if segments.empty?
|
|
47
|
-
query_scalar_or_vo_kind(type_name, value_objects_by_name)
|
|
48
|
-
else
|
|
49
|
-
vo = value_objects_by_name[type_name]
|
|
50
|
-
return :unknown unless vo
|
|
51
|
-
|
|
52
|
-
member = vo[:attributes].find { |a| a[:name].to_s == segments.first }
|
|
53
|
-
return :unknown if member.nil? || member[:list]
|
|
54
|
-
|
|
55
|
-
query_type_kind(member[:type].to_s, segments[1..] || [], value_objects_by_name)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def query_scalar_or_vo_kind(type_name, value_objects_by_name)
|
|
60
|
-
case type_name
|
|
61
|
-
when "String" then :string
|
|
62
|
-
when "Integer", "Float" then :number
|
|
63
|
-
when "TrueClass", "FalseClass" then :other
|
|
64
|
-
else
|
|
65
|
-
vo = value_objects_by_name[type_name]
|
|
66
|
-
vo ? query_vo_collapse_kind(vo, value_objects_by_name) : :unknown
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Mirrors `comparable` for a value object: a numeric member wins, a single member collapses
|
|
71
|
-
# to its own kind, anything else stays a whole JSON object.
|
|
72
|
-
def query_vo_collapse_kind(vo, value_objects_by_name)
|
|
73
|
-
return :number if vo[:attributes].any? { |a| %w[Integer Float].include?(a[:type].to_s) }
|
|
74
|
-
return query_type_kind(vo[:attributes].first[:type].to_s, [], value_objects_by_name) if vo[:attributes].size == 1
|
|
75
|
-
|
|
76
|
-
:other
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Resolves a `/` reference-hop field (`member/sponsor/standing`) against `aggregates_by_name`.
|
|
80
|
-
# Returns nil for a non-hop or an unresolvable one; otherwise
|
|
81
|
-
# `{via_field:, target_aggregate:, through:, target:, inner_field:}`, where `through` holds
|
|
82
|
-
# the steps after the first and `target` is the last aggregate the inner field is checked on.
|
|
83
|
-
HOP_CHAIN_LIMIT = 8 # Hecks::QuerySpecification::HopPath::MAX_HOPS
|
|
84
|
-
def query_hop_plan(aggregate, field, aggregates_by_name)
|
|
85
|
-
segments = field.to_s.split("/")
|
|
86
|
-
return nil if segments.size < 2 || segments.size - 1 > HOP_CHAIN_LIMIT
|
|
87
|
-
|
|
88
|
-
steps = []
|
|
89
|
-
current = aggregate
|
|
90
|
-
segments[0..-2].each do |segment|
|
|
91
|
-
via_attr = current[:attributes].find { |a| a[:name].to_s == segment }
|
|
92
|
-
return nil unless via_attr && reference_type?(via_attr[:type])
|
|
93
|
-
|
|
94
|
-
target_name = reference_target(via_attr[:type])
|
|
95
|
-
target = aggregates_by_name[target_name]
|
|
96
|
-
return nil unless target
|
|
97
|
-
|
|
98
|
-
steps << { via_field: segment, target_aggregate: target_name }
|
|
99
|
-
current = target
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
{ via_field: steps.first[:via_field], target_aggregate: steps.first[:target_aggregate], through: steps.drop(1),
|
|
103
|
-
target: current, inner_field: segments.last }
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# One where clause's eligibility: nil when generable, otherwise a skip reason.
|
|
107
|
-
def query_where_skip_reason(where, aggregate, value_objects_by_name)
|
|
108
|
-
field = where[:field].to_s
|
|
109
|
-
kind = query_field_kind(aggregate, field, value_objects_by_name)
|
|
110
|
-
if kind == :unknown
|
|
111
|
-
construct = field.include?("/") ? "reference_hop_where" : "where_unrecognized_field"
|
|
112
|
-
return skip(construct, "where clause on #{field.inspect} isn't a recognized attribute of this aggregate — a hop through a " \
|
|
113
|
-
"reference, an entity-scoped field, or simply undeclared here; cross-aggregate joins are read_model " \
|
|
114
|
-
"territory, not this generator's job")
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
raw_value = where[:value].to_s
|
|
118
|
-
return nil if raw_value.start_with?(":") # Symbol-valued — resolved from real, correctly-typed wire args at dispatch time
|
|
119
|
-
|
|
120
|
-
op = where[:op].to_s
|
|
121
|
-
unless QUERY_COMPARATOR_VARIANTS.key?(op)
|
|
122
|
-
return skip("where_none_in_state", "where clause on #{field.inspect} uses op #{op.inspect} — Vocabulary::QueryComparator admits it, " \
|
|
123
|
-
"and rust/src/kernel/query_comparators.rs's own QueryComparator::NoneInState variant now exists " \
|
|
124
|
-
"and is proven correct (item #9, whole-project table-unification survey) — but no generated " \
|
|
125
|
-
"domain has any way to hand it a cross-domain search list at the call site (named_query::run's " \
|
|
126
|
-
"own thin `run_cross_domain([])` wrapper is what every generated QUERIES table actually calls), " \
|
|
127
|
-
"so generating this condition today would silently answer every row 'true' rather than a real " \
|
|
128
|
-
"anti-join — deliberately left ungenerated until a real cross-domain-search call site exists, " \
|
|
129
|
-
"the same honest-refusal-over-silently-wrong choice this generator makes everywhere else")
|
|
130
|
-
end
|
|
131
|
-
if COMPARATORS_NEEDING_NUMERIC_FIDELITY.include?(op)
|
|
132
|
-
return nil if kind == :number
|
|
133
|
-
|
|
134
|
-
return skip("where_literal", "where clause on #{field.inspect} uses op #{op.inspect} against a LITERAL value whose target " \
|
|
135
|
-
"field doesn't reduce to a plain JSON number (kind: #{kind}) — gt/gte/lt/lte only mean anything " \
|
|
136
|
-
"against a number (query_comparators.rs's own `ordered?` gate)")
|
|
137
|
-
end
|
|
138
|
-
return nil if COMPARATORS_EXEMPT_FROM_LITERAL_TYPING.include?(op)
|
|
139
|
-
return nil if kind == :string
|
|
140
|
-
return nil if kind == :number
|
|
141
|
-
|
|
142
|
-
skip("where_literal", "where clause on #{field.inspect} uses op #{op.inspect} against a LITERAL value whose target field doesn't " \
|
|
143
|
-
"reduce to a plain JSON string (kind: #{kind}) — its true wire type can't be recovered from the exported IR")
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# A whole query's eligibility: nil when `emit_query_table` can bake it in, otherwise the
|
|
147
|
-
# first skip reason. order_by/limit content is checked last so the reason reported is the
|
|
148
|
-
# real remaining one.
|
|
149
|
-
def query_skip_reason(query, aggregate, value_objects_by_name, aggregates_by_name = {})
|
|
150
|
-
extras = %i[cursor consistency freshness inspection].select { |k| query[k] }
|
|
151
|
-
return skip(extras.first, "declares #{extras.join(', ')} — out of scope for this generator (rust/project/queries.rb's own " \
|
|
152
|
-
"header has the full argument)") if extras.any?
|
|
153
|
-
return skip("index_hints", "declares use_index, out of scope for the same reason the extras above are") if Array(query[:index_hints]).any?
|
|
154
|
-
|
|
155
|
-
# A query with no where clause but a declared tenant still has logic: the tenant check.
|
|
156
|
-
declared_tenant = query[:authorization] && query[:authorization][:tenant]
|
|
157
|
-
return skip("no_wheres", "declares no where clauses at all — nothing for filter_entries to bake in") if Array(query[:wheres]).empty? && !declared_tenant
|
|
158
|
-
|
|
159
|
-
# A single hop is generated via `kernel::read_model::apply_reference_hops`; its inner
|
|
160
|
-
# clause is checked against the target aggregate. A longer chain is refused.
|
|
161
|
-
query[:wheres].each do |where|
|
|
162
|
-
hop = query_hop_plan(aggregate, where[:field].to_s, aggregates_by_name)
|
|
163
|
-
if hop
|
|
164
|
-
target_value_objects_by_name = hop[:target][:value_objects].to_h { |vo| [vo[:name], vo] }
|
|
165
|
-
reason = query_where_skip_reason(where.merge(field: hop[:inner_field]), hop[:target], target_value_objects_by_name)
|
|
166
|
-
return reskip(reason, "hop through #{hop[:via_field]} to #{hop[:target_aggregate]}'s own #{reason}") if reason
|
|
167
|
-
|
|
168
|
-
next
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
reason = query_where_skip_reason(where, aggregate, value_objects_by_name)
|
|
172
|
-
return reason if reason
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
auth_reason = declared_authorization_skip_reason(query[:authorization], aggregate, value_objects_by_name)
|
|
176
|
-
return auth_reason if auth_reason
|
|
177
|
-
|
|
178
|
-
order_reason = declared_order_by_skip_reason(query[:order_by], aggregate, value_objects_by_name)
|
|
179
|
-
return order_reason if order_reason
|
|
180
|
-
|
|
181
|
-
offset_reason = declared_offset_skip_reason(query[:offset])
|
|
182
|
-
return offset_reason if offset_reason
|
|
183
|
-
|
|
184
|
-
declared_limit_skip_reason(query[:limit])
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# `authorize policy, tenant: :field`: no tenant is a no-op, as in `TenantScope.apply`;
|
|
188
|
-
# a tenant field gets the same check as a where clause on it.
|
|
189
|
-
def declared_authorization_skip_reason(authorization, aggregate, value_objects_by_name)
|
|
190
|
-
tenant = authorization && authorization[:tenant]
|
|
191
|
-
return nil unless tenant
|
|
192
|
-
|
|
193
|
-
synthetic_where = { field: tenant, op: "eq", value: ":#{tenant}" }
|
|
194
|
-
reason = query_where_skip_reason(synthetic_where, aggregate, value_objects_by_name)
|
|
195
|
-
reason && skip("authorization", reason)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Whether `order_by` names a field that reduces to a plain string or number.
|
|
199
|
-
def declared_order_by_skip_reason(order_by, aggregate, value_objects_by_name)
|
|
200
|
-
return nil unless order_by
|
|
201
|
-
|
|
202
|
-
field = order_by[:field].to_s
|
|
203
|
-
kind = query_field_kind(aggregate, field, value_objects_by_name)
|
|
204
|
-
return nil if %i[string number].include?(kind)
|
|
205
|
-
|
|
206
|
-
skip("order_by", "declares order_by on #{field.inspect} — this generator can only sort a field that reduces to a plain " \
|
|
207
|
-
"JSON string or number (kind: #{kind}); a hop through a reference, an entity-scoped field, a list_of " \
|
|
208
|
-
"field, or a multi-member non-numeric value object can't be compared generically")
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# `limit` arrives through `render_value` as text, so an integer literal is told apart from a
|
|
212
|
-
# non-numeric one here; a Symbol arg resolves from typed wire args at dispatch.
|
|
213
|
-
def declared_limit_skip_reason(limit)
|
|
214
|
-
return nil unless limit
|
|
215
|
-
|
|
216
|
-
raw = limit[:value].to_s
|
|
217
|
-
return nil if raw.start_with?(":") || raw.match?(/\A-?\d+\z/)
|
|
218
|
-
|
|
219
|
-
skip("limit", "declares limit #{raw.inspect} — not a literal integer or a caller-bound Symbol arg, so this generator " \
|
|
220
|
-
"can't compile a real limit count from it")
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# `offset` has the same wire shape as `limit`; kept separate so the reason names "offset".
|
|
224
|
-
def declared_offset_skip_reason(offset)
|
|
225
|
-
return nil unless offset
|
|
226
|
-
|
|
227
|
-
raw = offset[:value].to_s
|
|
228
|
-
return nil if raw.start_with?(":") || raw.match?(/\A-?\d+\z/)
|
|
229
|
-
|
|
230
|
-
skip("offset", "declares offset #{raw.inspect} — not a literal integer or a caller-bound Symbol arg, so this generator " \
|
|
231
|
-
"can't compile a real offset count from it")
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# `order_by`'s compiled form. `null_semantics` is a sibling key in the IR, passed in
|
|
235
|
-
# separately and folded into the `OrderBy` struct.
|
|
236
|
-
def emit_query_order_by(order_by, null_semantics = nil)
|
|
237
|
-
descending = order_by[:direction].to_s == "desc" ? "true" : "false"
|
|
238
|
-
"crate::kernel::query_ordering::OrderBy { field: #{order_by[:field].to_s.inspect}, descending: #{descending}, " \
|
|
239
|
-
"nulls: #{null_semantics_variant(null_semantics)} }"
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# An unrecognized `nulls` mode falls back to native ordering, as in
|
|
243
|
-
# `QuerySpecification::Common::NullPolicy.order`; the default never reaches the wire.
|
|
244
|
-
NULLS_MODE_VARIANTS = { "first" => "First", "last" => "Last" }.freeze
|
|
245
|
-
|
|
246
|
-
def null_semantics_variant(null_semantics)
|
|
247
|
-
mode = null_semantics && null_semantics[:mode].to_s
|
|
248
|
-
"crate::kernel::query_ordering::NullsMode::#{NULLS_MODE_VARIANTS.fetch(mode, 'Native')}"
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# `limit`'s compiled form; `declared_limit_skip_reason` has already vetted the literal.
|
|
252
|
-
def emit_query_limit(limit)
|
|
253
|
-
raw = limit[:value].to_s
|
|
254
|
-
return "crate::kernel::query_ordering::Limit::Arg(#{raw.delete_prefix(':').inspect})" if raw.start_with?(":")
|
|
255
|
-
|
|
256
|
-
"crate::kernel::query_ordering::Limit::Literal(#{raw.to_i})"
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
# `offset`'s compiled form: `Offset` aliases `Limit`, so this reuses `emit_query_limit`
|
|
260
|
-
# and respells the type so the generated code reads naturally.
|
|
261
|
-
def emit_query_offset(offset) = emit_query_limit(offset).sub("query_ordering::Limit::", "query_ordering::Offset::")
|
|
262
|
-
|
|
263
|
-
# Where clauses as `arg:` (Symbol value) or `literal:` conditions. A string literal is
|
|
264
|
-
# decoded with `Literal.read`; the raw wire text still carries its quote marks and would
|
|
265
|
-
# silently match nothing.
|
|
266
|
-
def query_conditions(query)
|
|
267
|
-
query[:wheres].map do |where|
|
|
268
|
-
raw_value = where[:value].to_s
|
|
269
|
-
symbol = raw_value.start_with?(":")
|
|
270
|
-
{
|
|
271
|
-
field: where[:field].to_s,
|
|
272
|
-
op: where[:op].to_s,
|
|
273
|
-
arg: symbol ? raw_value.delete_prefix(":") : nil,
|
|
274
|
-
literal: symbol ? nil : Hecks::Literal.read(raw_value),
|
|
275
|
-
}
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# `Runtime::TenantScope.apply`'s synthetic `field == args[tenant]` clause, appended at
|
|
280
|
-
# codegen time. Query-only: read models still refuse `authorize`.
|
|
281
|
-
def query_conditions_with_authorization(query)
|
|
282
|
-
tenant = query[:authorization] && query[:authorization][:tenant]
|
|
283
|
-
return query_conditions(query) unless tenant
|
|
284
|
-
|
|
285
|
-
query_conditions(query) << { field: tenant.to_s, op: "eq", arg: tenant.to_s, literal: nil }
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# Splits a query's where clauses into local conditions (plus the tenant clause) and
|
|
289
|
-
# single-hop `ReferenceHopCondition`s.
|
|
290
|
-
def query_conditions_and_hops(domain_name, query, aggregate, aggregates_by_name)
|
|
291
|
-
local, hops = Array(query[:wheres]).partition { |where| query_hop_plan(aggregate, where[:field].to_s, aggregates_by_name).nil? }
|
|
292
|
-
[query_conditions_with_authorization(query.merge(wheres: local)),
|
|
293
|
-
read_model_hop_conditions(domain_name, hops, aggregate, aggregates_by_name)]
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# `TenantAuth`'s compiled form; nil without a declared tenant. `policy` is carried, not
|
|
297
|
-
# enforced, as in Ruby.
|
|
298
|
-
def emit_query_authorization(query_name, authorization)
|
|
299
|
-
tenant = authorization && authorization[:tenant]
|
|
300
|
-
return nil unless tenant
|
|
301
|
-
|
|
302
|
-
policy = authorization[:policy]
|
|
303
|
-
"crate::kernel::named_query::TenantAuth { query_name: #{query_name.to_s.inspect}, tenant_field: #{tenant.to_s.inspect}, policy: #{policy.to_s.inspect} }"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Maps a where `op` to its Rust `QueryComparator` variant. `none_in_state` is omitted on
|
|
307
|
-
# purpose: no generated call site can supply a cross-domain search list, so it would
|
|
308
|
-
# answer every row `true` (see `query_where_skip_reason`).
|
|
309
|
-
QUERY_COMPARATOR_VARIANTS = {
|
|
310
|
-
"eq" => "Eq", "ne" => "Ne", "gt" => "Gt", "gte" => "Gte",
|
|
311
|
-
"lt" => "Lt", "lte" => "Lte", "in" => "In", "contains" => "Contains",
|
|
312
|
-
}.freeze
|
|
313
|
-
|
|
314
|
-
def query_comparator_variant(op)
|
|
315
|
-
QUERY_COMPARATOR_VARIANTS.fetch(op) do
|
|
316
|
-
raise "unknown query comparator #{op.inspect} — query_comparator_variant doesn't cover this shape " \
|
|
317
|
-
"(grammar-validated at declare time, so this should be unreachable for a real declared query; " \
|
|
318
|
-
"query_where_skip_reason should have already skipped it with an honest reason)"
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
def emit_query_condition_value(condition)
|
|
323
|
-
if condition[:arg]
|
|
324
|
-
"crate::kernel::QueryConditionValue::Arg(#{condition[:arg].inspect})"
|
|
325
|
-
elsif condition[:literal].is_a?(Numeric)
|
|
326
|
-
# Only a numeric-kind target reaches here with a Numeric literal, so its class picks
|
|
327
|
-
# the variant.
|
|
328
|
-
"crate::kernel::QueryConditionValue::NumericLiteral(#{Float(condition[:literal]).inspect})"
|
|
329
|
-
else
|
|
330
|
-
"crate::kernel::QueryConditionValue::Literal(#{condition[:literal].inspect})"
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def emit_query_condition(condition)
|
|
335
|
-
comparator_expr = "crate::kernel::query_comparators::QueryComparator::#{query_comparator_variant(condition[:op])}"
|
|
336
|
-
"crate::kernel::QueryCondition { field: #{condition[:field].inspect}, comparator: #{comparator_expr}, " \
|
|
337
|
-
"value: #{emit_query_condition_value(condition)} },"
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
# `order_by`/`offset`/`limit`/`authorization` are nil unless the query declared them.
|
|
341
|
-
def emit_query_def(query_def)
|
|
342
|
-
conditions = query_def[:conditions].map { |c| " #{emit_query_condition(c)}" }.join("\n")
|
|
343
|
-
reference_hop_conditions = Array(query_def[:reference_hop_conditions]).map { |h| " #{emit_reference_hop_condition(h)}" }.join("\n")
|
|
344
|
-
order_by = query_def[:order_by] ? "Some(#{query_def[:order_by]})" : "None"
|
|
345
|
-
offset = query_def[:offset] ? "Some(#{query_def[:offset]})" : "None"
|
|
346
|
-
limit = query_def[:limit] ? "Some(#{query_def[:limit]})" : "None"
|
|
347
|
-
authorization = query_def[:authorization] ? "Some(#{query_def[:authorization]})" : "None"
|
|
348
|
-
|
|
349
|
-
<<~RUST.rstrip
|
|
350
|
-
crate::kernel::QueryDef {
|
|
351
|
-
verb: #{query_def[:verb].inspect},
|
|
352
|
-
aggregate: #{query_def[:aggregate].inspect},
|
|
353
|
-
conditions: &[
|
|
354
|
-
#{conditions}
|
|
355
|
-
],
|
|
356
|
-
reference_hop_conditions: &[
|
|
357
|
-
#{reference_hop_conditions}
|
|
358
|
-
],
|
|
359
|
-
order_by: #{order_by},
|
|
360
|
-
offset: #{offset},
|
|
361
|
-
limit: #{limit},
|
|
362
|
-
authorization: #{authorization},
|
|
363
|
-
},
|
|
364
|
-
RUST
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
# Text of the `TMPL:query_table` row in `rust/src/exemplar/queries.rs`; `Exemplar.render`
|
|
368
|
-
# substitutes it as an exact substring, so the spacing must match.
|
|
369
|
-
QUERY_TABLE_ROW_PLACEHOLDER = <<~RUST.rstrip
|
|
370
|
-
crate::kernel::QueryDef {
|
|
371
|
-
verb: "tmpl_verb",
|
|
372
|
-
aggregate: "tmpl_aggregate",
|
|
373
|
-
conditions: &[
|
|
374
|
-
crate::kernel::QueryCondition {
|
|
375
|
-
field: "tmpl_field",
|
|
376
|
-
comparator: crate::kernel::query_comparators::QueryComparator::Eq,
|
|
377
|
-
value: crate::kernel::QueryConditionValue::Literal("tmpl_literal"),
|
|
378
|
-
},
|
|
379
|
-
],
|
|
380
|
-
reference_hop_conditions: &[
|
|
381
|
-
crate::kernel::read_model::ReferenceHopCondition {
|
|
382
|
-
via_field: "tmpl_via_field",
|
|
383
|
-
target_aggregate: "tmpl_target_aggregate",
|
|
384
|
-
through: &[crate::kernel::read_model::HopStep { via_field: "tmpl_via_field", target_aggregate: "tmpl_target_aggregate" }],
|
|
385
|
-
inner_field: "tmpl_inner_field",
|
|
386
|
-
inner_comparator: crate::kernel::query_comparators::QueryComparator::Eq,
|
|
387
|
-
inner_value: crate::kernel::QueryConditionValue::Literal("tmpl_literal"),
|
|
388
|
-
},
|
|
389
|
-
],
|
|
390
|
-
order_by: Some(crate::kernel::query_ordering::OrderBy { field: "tmpl_order_field", descending: true, nulls: crate::kernel::query_ordering::NullsMode::Last }),
|
|
391
|
-
offset: Some(crate::kernel::query_ordering::Offset::Literal(1)),
|
|
392
|
-
limit: Some(crate::kernel::query_ordering::Limit::Literal(5)),
|
|
393
|
-
authorization: Some(crate::kernel::named_query::TenantAuth { query_name: "tmpl_query_name", tenant_field: "tmpl_tenant_field", policy: "tmpl_policy" }),
|
|
394
|
-
},
|
|
395
|
-
RUST
|
|
396
|
-
|
|
397
|
-
# The `QUERIES` table `kernel::named_query::run` walks, one row per generable query, then
|
|
398
|
-
# the authorization-assignments constant and the entity-query table.
|
|
399
|
-
def emit_query_table(query_defs)
|
|
400
|
-
entity_defs, aggregate_defs = query_defs.partition { |q| q[:entity] }
|
|
401
|
-
rows = aggregate_defs.map { |q| emit_query_def(q) }
|
|
402
|
-
"#{Exemplar.render('query_table', QUERY_TABLE_ROW_PLACEHOLDER => rows.join("\n"))}\n" \
|
|
403
|
-
"#{emit_authorization_assignments(query_defs)}" \
|
|
404
|
-
"#{emit_entity_query_table(entity_defs)}"
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
# The chapter-local verb `provides "authorization", assignments:` names, or nil.
|
|
408
|
-
def provided_assignments(ir)
|
|
409
|
-
Array(ir[:provides]).find { |row| row[:capability] == "authorization" && row[:key] == "assignments" }&.dig(:verb)
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# The `AUTHORIZATION_ASSIGNMENTS` constant `kernel::check_role_via` reads; a merged union
|
|
413
|
-
# carries its chapters' `assignments` flag with their defs.
|
|
414
|
-
def emit_authorization_assignments(query_defs)
|
|
415
|
-
verb = query_defs.find { |q| q[:assignments] }&.dig(:verb)
|
|
416
|
-
value = verb ? "Some(#{verb.inspect})" : "None"
|
|
417
|
-
"/// `provides \"authorization\", assignments:` — the query `kernel::check_role_via` reads; " \
|
|
418
|
-
"`None` when no chapter here declares one.\n" \
|
|
419
|
-
"pub const AUTHORIZATION_ASSIGNMENTS: Option<&str> = #{value};\n"
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# `ENTITY_QUERIES`: declared `Aggregate.Entity.Query` rows for `named_query::run_entity`.
|
|
423
|
-
def emit_entity_query_table(entity_defs)
|
|
424
|
-
rows = entity_defs.map { |q| "#{emit_entity_query_def(q)}\n" }.join
|
|
425
|
-
"/// Declared entity queries (`Aggregate.Entity.Query`) — `kernel::named_query::run_entity`.\n" \
|
|
426
|
-
"pub const ENTITY_QUERIES: &[crate::kernel::named_query::EntityQueryDef] = &[\n#{rows}];\n"
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def emit_entity_query_def(query_def)
|
|
430
|
-
entity = query_def[:entity]
|
|
431
|
-
conditions = query_def[:conditions].map { |c| " #{emit_query_condition(c)}" }.join("\n")
|
|
432
|
-
keys = entity[:identity_keys].map(&:inspect).join(", ")
|
|
433
|
-
order_by = query_def[:order_by] ? "Some(#{query_def[:order_by]})" : "None"
|
|
434
|
-
offset = query_def[:offset] ? "Some(#{query_def[:offset]})" : "None"
|
|
435
|
-
limit = query_def[:limit] ? "Some(#{query_def[:limit]})" : "None"
|
|
436
|
-
"crate::kernel::named_query::EntityQueryDef {\n verb: #{query_def[:verb].inspect},\n " \
|
|
437
|
-
"aggregate: #{query_def[:aggregate].inspect},\n list_field: #{entity[:list_field].inspect},\n " \
|
|
438
|
-
"parent_key: #{entity[:parent_key].inspect},\n identity_keys: &[#{keys}],\n conditions: &[\n#{conditions}\n ],\n " \
|
|
439
|
-
"order_by: #{order_by},\n offset: #{offset},\n limit: #{limit},\n},"
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# An entity query needs a list attribute holding the entity; tenant scope is not generated.
|
|
443
|
-
# Its clauses are otherwise checked as on an aggregate query, against the entity's fields.
|
|
444
|
-
def entity_query_skip_reason(query, entity, list_attr, value_objects_by_name)
|
|
445
|
-
return skip("entity_query", "#{entity[:name]} is held in no list attribute on its aggregate — nothing to flatten") unless list_attr
|
|
446
|
-
return skip("entity_query_authorization", "declares authorize — an entity query's tenant scope is not generated yet") if query[:authorization]
|
|
447
|
-
|
|
448
|
-
query_skip_reason(query, entity, value_objects_by_name)
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
# `Hecks::Naming.snake`.
|
|
452
|
-
def snake(text)
|
|
453
|
-
text.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
# Builds and invariant-checks each value-object argument of a named query, as
|
|
457
|
-
# `QueryInterpreter#normalize_args` does; the typed value is only a gate, the comparison
|
|
458
|
-
# reads raw JSON. A closed set's `from_json` already refuses non-members, so it is not
|
|
459
|
-
# invariant-checked. Only the aggregate's own value objects are typed.
|
|
460
|
-
def query_arg_checks(query, mod_path, value_objects_by_name)
|
|
461
|
-
query[:attributes].filter_map do |attr|
|
|
462
|
-
next if attr[:list] || attr[:relationship]
|
|
463
|
-
|
|
464
|
-
vo = value_objects_by_name[attr[:type]]
|
|
465
|
-
next unless vo
|
|
466
|
-
|
|
467
|
-
key = rust_field(attr[:name])
|
|
468
|
-
build = composite_from_json_expr(attr, value_objects_by_name, "x")
|
|
469
|
-
check = vo[:closed_set] ? "" : ".check_invariants()?"
|
|
470
|
-
"if let Some(x) = args.get(#{key.inspect}) { #{mod_path}::#{build}#{check}; }"
|
|
471
|
-
end
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
# `check_query_args`, the gate `kernel/cli.rs` runs before `named_query::run`: one arm per
|
|
475
|
-
# query with a typed argument, `Ok(())` otherwise.
|
|
476
|
-
def emit_query_arg_check_table(query_defs)
|
|
477
|
-
arms = query_defs.reject { |q| q[:arg_checks].empty? }.map do |q|
|
|
478
|
-
lines = q[:arg_checks].map { |line| " #{line}" }.join("\n")
|
|
479
|
-
" #{q[:verb].inspect} => {\n#{lines}\n Ok(())\n }"
|
|
480
|
-
end
|
|
481
|
-
<<~RUST
|
|
482
|
-
/// C3.7 for a named query's own arguments — `query_arg_checks`
|
|
483
|
-
/// (rust/project/queries.rb) has the full story.
|
|
484
|
-
pub fn check_query_args(verb: &str, args: &crate::kernel::Json) -> Result<(), crate::kernel::Refusal> {
|
|
485
|
-
match verb {
|
|
486
|
-
#{arms.join("\n")}
|
|
487
|
-
_ => Ok(()),
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
RUST
|
|
491
|
-
end
|
|
492
|
-
end
|
|
493
|
-
end
|