hecks 3.2.0 → 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/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/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- 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 +2 -0
- 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/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/needs.rs +94 -27
- 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 +3 -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/mutations.rb
DELETED
|
@@ -1,695 +0,0 @@
|
|
|
1
|
-
require "hecks/rust_build/append_optionals"
|
|
2
|
-
|
|
3
|
-
module RustProjection
|
|
4
|
-
module Projector
|
|
5
|
-
module_function
|
|
6
|
-
|
|
7
|
-
# True when a list attribute reads `nil` rather than `[]`: a creating command `sets` it from
|
|
8
|
-
# an optional argument the caller omitted (`CardPayment.tags`). `Account.ledger` stays `[]`.
|
|
9
|
-
def list_attr_creation_optional?(aggregate, attr_name, value_objects_by_name)
|
|
10
|
-
aggregate[:commands].any? do |command|
|
|
11
|
-
next false unless creates_owner?(aggregate, command, value_objects_by_name)
|
|
12
|
-
|
|
13
|
-
command[:mutations].any? do |m|
|
|
14
|
-
next false unless m[:op].to_s == "set" && m[:target].to_s == attr_name.to_s && m[:source][:kind] == "argument"
|
|
15
|
-
|
|
16
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name].to_s }
|
|
17
|
-
source_attr && source_attr[:optional]
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Whether `command` builds the owner record from scratch. Needs `references: nil` plus a
|
|
23
|
-
# supplied required owner field, by `:set` or a same-named argument. Arguments claimed by an
|
|
24
|
-
# `append` don't count (`ValueObject.Member#position` is not the owner's). Completeness of
|
|
25
|
-
# state is not the test: `Analyzer#complete_state?` disagrees on both sides.
|
|
26
|
-
def creates_owner?(aggregate, command, value_objects_by_name)
|
|
27
|
-
return false unless command[:references].nil?
|
|
28
|
-
|
|
29
|
-
owner_fields = aggregate[:attributes].map { |a| a[:name].to_s }.to_set
|
|
30
|
-
owner_fields << aggregate[:lifecycle][:field].to_s if aggregate[:lifecycle]
|
|
31
|
-
required_fields = aggregate[:attributes].reject { |a| a[:list] || a[:optional] }.map { |a| a[:name].to_s }.to_set
|
|
32
|
-
|
|
33
|
-
# Arguments claimed by an append never bare-name-match an owner field.
|
|
34
|
-
append_claimed = Set.new
|
|
35
|
-
command[:mutations].each do |m|
|
|
36
|
-
next unless m[:op].to_s == "append"
|
|
37
|
-
|
|
38
|
-
Array(m[:fields]&.values).each do |v|
|
|
39
|
-
source = append_field_source(v)
|
|
40
|
-
append_claimed << source.to_s if source.is_a?(Symbol)
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
known_writes = Set.new
|
|
45
|
-
|
|
46
|
-
# A same-named argument is copied across by `record_fields` with no `:set`.
|
|
47
|
-
command[:attributes].each do |attr|
|
|
48
|
-
name = attr[:name].to_s
|
|
49
|
-
known_writes << name if owner_fields.include?(name) && !append_claimed.include?(name)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# A `:set` covers the source-from-another-argument shape (`sets :field, to: :other_arg`).
|
|
53
|
-
command[:mutations].each do |m|
|
|
54
|
-
next unless m[:op].to_s == "set"
|
|
55
|
-
|
|
56
|
-
target = m[:target].to_s
|
|
57
|
-
known_writes << target if owner_fields.include?(target)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
required_fields.any? { |field| known_writes.include?(field) }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Resolves an `append` target to its entity or value object, or nil if it is neither.
|
|
64
|
-
def append_element(aggregate, target_type, value_objects_by_name)
|
|
65
|
-
Hecks::RustBuild::AppendOptionals.element(aggregate, target_type, value_objects_by_name)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Identity attribute and its single-field value object for an auto-minted entity element,
|
|
69
|
-
# or nil for a composite or non-bare identity.
|
|
70
|
-
def entity_identity_mint(entity, value_objects_by_name)
|
|
71
|
-
id_path = entity[:identified_by]&.first
|
|
72
|
-
return nil unless id_path
|
|
73
|
-
|
|
74
|
-
head, *rest = id_path.split(".")
|
|
75
|
-
return nil unless rest.size == 1
|
|
76
|
-
|
|
77
|
-
attr = entity[:attributes].find { |a| a[:name].to_s == head }
|
|
78
|
-
return nil unless attr
|
|
79
|
-
|
|
80
|
-
vo = value_objects_by_name[attr[:type]]
|
|
81
|
-
return nil unless vo && !vo[:closed_set] && vo[:attributes].size == 1 && vo[:attributes].first[:name].to_s == rest.first
|
|
82
|
-
|
|
83
|
-
[attr, vo]
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Guard for a whole-list `:set` of entities: refuses duplicate identities pairwise.
|
|
87
|
-
# Returns `[guard_text, effective_rhs]`. Every `identified_by` head is compared at once (not
|
|
88
|
-
# just the first), so a composite identity is guarded the same as a single-field one — see
|
|
89
|
-
# `check_entity_collision` in `entity_element.rb` (Ruby's runtime `heads.all?`).
|
|
90
|
-
def entity_list_replace_guard(aggregate, target_attr, target_field, rhs, value_objects_by_name)
|
|
91
|
-
entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
|
|
92
|
-
return ["", rhs] unless entity && entity[:identified_by]&.any?
|
|
93
|
-
|
|
94
|
-
checks = []
|
|
95
|
-
offered_exprs = []
|
|
96
|
-
entity[:identified_by].each do |path|
|
|
97
|
-
id_head = path.to_s.split(".").first
|
|
98
|
-
id_attr = entity[:attributes].find { |a| a[:name].to_s == id_head }
|
|
99
|
-
return ["", rhs] unless id_attr
|
|
100
|
-
|
|
101
|
-
id_field = rust_ident_field(id_attr[:name])
|
|
102
|
-
checks << "prior.#{id_field} == e.#{id_field}"
|
|
103
|
-
|
|
104
|
-
# Single-field unwrap, so `format!` prints the bare scalar as Ruby's refusal wording does.
|
|
105
|
-
id_vo = value_objects_by_name[id_attr[:type]]
|
|
106
|
-
offered_exprs <<
|
|
107
|
-
if id_vo && !id_vo[:closed_set] && id_vo[:attributes].size == 1
|
|
108
|
-
"e.#{id_field}.#{rust_ident_field(id_vo[:attributes].first[:name])}"
|
|
109
|
-
else
|
|
110
|
-
"e.#{id_field}"
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
local_var = "replaced_#{target_field}"
|
|
115
|
-
entity_lit = entity[:name].to_s.inspect
|
|
116
|
-
aggregate_lit = aggregate[:name].to_s.inspect
|
|
117
|
-
identity_lit = entity[:identified_by].join(", ").inspect
|
|
118
|
-
condition = checks.join(" && ")
|
|
119
|
-
offered_lets = offered_exprs.each_with_index.map { |expr, i| "let offered_#{i} = format!(\"{:?}\", #{expr});" }
|
|
120
|
-
offered_refs = offered_exprs.each_index.map { |i| "offered_#{i}.as_str()" }
|
|
121
|
-
guard =
|
|
122
|
-
"let #{local_var} = #{rhs};\n " \
|
|
123
|
-
"for (i, e) in #{local_var}.iter().enumerate() { if #{local_var}[..i].iter().any(|prior| #{condition}) " \
|
|
124
|
-
"{ #{offered_lets.join(' ')} let offered = [#{offered_refs.join(', ')}]; " \
|
|
125
|
-
"return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
|
|
126
|
-
"{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
|
|
127
|
-
"offered: &offered }.render_args())); } }\n "
|
|
128
|
-
[guard, local_var]
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# The composite-identity twin of the single-field collision guard inlined in
|
|
132
|
-
# `emit_mutation_line_body`'s `append` branch: builds a duplicate check across every
|
|
133
|
-
# `identified_by` head at once (`e.batch == ... && e.sequence == ...`), matching
|
|
134
|
-
# `EntityElement.check_entity_collision`'s `heads.all?` on the Ruby runtime side. Called only
|
|
135
|
-
# once every head is confirmed present in the append's own `fields` map (composite identities
|
|
136
|
-
# never mint). Kept byte-for-byte with `rust/codegen/src/mutations.rs`'s
|
|
137
|
-
# `composite_append_collision_guard`.
|
|
138
|
-
def composite_append_collision_guard(heads, fields, entity, command, value_objects_by_name, aggregate, target_field, identified_by)
|
|
139
|
-
checks = []
|
|
140
|
-
offered_exprs = []
|
|
141
|
-
heads.each do |head|
|
|
142
|
-
field_attr = entity[:attributes].find { |a| a[:name].to_s == head }
|
|
143
|
-
_, source = fields.find { |field_name, _| field_name.to_s == head }
|
|
144
|
-
rhs = append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)
|
|
145
|
-
id_field = rust_ident_field(head)
|
|
146
|
-
checks << "e.#{id_field} == #{rhs}"
|
|
147
|
-
|
|
148
|
-
# Unwrap a single-field identity component so `offered` prints the bare scalar, as
|
|
149
|
-
# `Rendering.describe` does; Debug-printing the value object would give
|
|
150
|
-
# `LineSequence { value: 654 }`.
|
|
151
|
-
vo = value_objects_by_name[field_attr[:type]]
|
|
152
|
-
offered_exprs <<
|
|
153
|
-
if vo && !vo[:closed_set] && vo[:attributes].size == 1
|
|
154
|
-
"#{rhs}.#{rust_ident_field(vo[:attributes].first[:name])}"
|
|
155
|
-
else
|
|
156
|
-
rhs
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
entity_lit = entity[:name].to_s.inspect
|
|
161
|
-
aggregate_lit = aggregate[:name].to_s.inspect
|
|
162
|
-
identity_lit = identified_by.join(", ").inspect
|
|
163
|
-
condition = checks.join(" && ")
|
|
164
|
-
offered_lets = offered_exprs.each_with_index.map { |expr, i| "let offered_#{i} = format!(\"{:?}\", #{expr});" }
|
|
165
|
-
offered_refs = offered_exprs.each_index.map { |i| "offered_#{i}.as_str()" }
|
|
166
|
-
|
|
167
|
-
"if record.#{target_field}.iter().any(|e| #{condition}) " \
|
|
168
|
-
"{ #{offered_lets.join(' ')} let offered = [#{offered_refs.join(', ')}]; " \
|
|
169
|
-
"return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
|
|
170
|
-
"{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
|
|
171
|
-
"offered: &offered }.render_args())); }\n "
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Why an `append` can't be generated: an unresolvable element, an undeclared field, a source
|
|
175
|
-
# that doesn't bridge, or an entity identity that is neither minted nor supplied.
|
|
176
|
-
def append_field_problems(command, aggregate, value_objects_by_name)
|
|
177
|
-
command[:mutations].select { |m| m[:op].to_s == "append" }.flat_map do |m|
|
|
178
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
|
|
179
|
-
element = target_attr && append_element(aggregate, target_attr[:type], value_objects_by_name)
|
|
180
|
-
next ["#{m[:target]}: element type #{target_attr&.dig(:type).inspect} not resolvable"] unless element
|
|
181
|
-
|
|
182
|
-
problems = m[:fields].filter_map do |field_name, source|
|
|
183
|
-
field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
|
|
184
|
-
next "#{m[:target]}.#{field_name}: not a declared field" unless field_attr
|
|
185
|
-
|
|
186
|
-
# A Symbol names an argument; anything else is the value (Hecks::Literal).
|
|
187
|
-
parsed = append_field_source(source)
|
|
188
|
-
next nil if parsed.is_a?(Hecks::StateRef) # checked by state_source_problems
|
|
189
|
-
next literal_problem(m, field_name, parsed, field_attr, value_objects_by_name) unless parsed.is_a?(Symbol)
|
|
190
|
-
|
|
191
|
-
arg_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
|
|
192
|
-
if arg_attr.nil?
|
|
193
|
-
"#{m[:target]}.#{field_name}: sources undeclared argument #{parsed}"
|
|
194
|
-
elsif !bridgeable_value_types?(arg_attr[:type], field_attr[:type], value_objects_by_name)
|
|
195
|
-
"#{m[:target]}.#{field_name}: #{arg_attr[:type]} doesn't bridge to #{field_attr[:type]}"
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
|
|
200
|
-
next problems unless entity
|
|
201
|
-
|
|
202
|
-
present = m[:fields].keys.map(&:to_s)
|
|
203
|
-
id_head = entity[:identified_by]&.first.to_s.split(".").first
|
|
204
|
-
problems << "#{m[:target]}: #{entity[:name]}'s identity doesn't auto-mint" if !present.include?(id_head) && !entity_identity_mint(entity, value_objects_by_name)
|
|
205
|
-
problems
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Why a `remove:` can't be generated. Only entity-typed lists are supported, matched by a
|
|
210
|
-
# single bridgeable identity field, the same shape `entity_identity_mint` requires.
|
|
211
|
-
def remove_field_problems(command, aggregate, value_objects_by_name)
|
|
212
|
-
command[:mutations].select { |m| m[:op].to_s == "remove" }.filter_map do |m|
|
|
213
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
|
|
214
|
-
next "#{m[:target]}: not a declared list attribute" unless target_attr && target_attr[:list]
|
|
215
|
-
|
|
216
|
-
entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
|
|
217
|
-
next "#{m[:target]}: remove on a value-object-typed list is not generated yet" unless entity
|
|
218
|
-
|
|
219
|
-
# `entity_identity_mint` inspects only the first head, so a composite identity is
|
|
220
|
-
# excluded here.
|
|
221
|
-
if Array(entity[:identified_by]).size != 1
|
|
222
|
-
next "#{m[:target]}: #{entity[:name]}'s identity is composite — remove not generated yet"
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
id_attr, = entity_identity_mint(entity, value_objects_by_name)
|
|
226
|
-
unless id_attr
|
|
227
|
-
next "#{m[:target]}: #{entity[:name]}'s identity isn't a single bridgeable field — remove not generated yet"
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
unless m[:source].is_a?(Hash) && m[:source][:kind] == "argument"
|
|
231
|
-
next "#{m[:target]}: remove sources a literal or record state, not an argument — not generated yet"
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name].to_s }
|
|
235
|
-
next "#{m[:target]}: remove sources undeclared argument #{m[:source][:name]}" unless source_attr
|
|
236
|
-
|
|
237
|
-
unless bridgeable_value_types?(source_attr[:type], id_attr[:type], value_objects_by_name)
|
|
238
|
-
next "#{m[:target]}: #{source_attr[:type]} doesn't bridge to #{id_attr[:type]}"
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# Decodes an append field source: a Symbol names a command argument, anything else is a
|
|
246
|
-
# literal.
|
|
247
|
-
def append_field_source(source) = Hecks::Bluebook::Assembly::Marks.read(source)
|
|
248
|
-
|
|
249
|
-
def literal_problem(mutation, field_name, literal, field_attr, value_objects_by_name)
|
|
250
|
-
return nil if literal_set_bridgeable?(literal, field_attr[:type], value_objects_by_name)
|
|
251
|
-
|
|
252
|
-
"#{mutation[:target]}.#{field_name}: literal doesn't bridge to #{field_attr[:type]}"
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Collapses the transition rows a command names into the `field`/`from_states` hash
|
|
256
|
-
# TransitionCheck reads.
|
|
257
|
-
def lifecycle_transition_for(command, aggregate)
|
|
258
|
-
return nil unless aggregate[:lifecycle]
|
|
259
|
-
|
|
260
|
-
rows = aggregate[:lifecycle][:transitions].select { |t| t[:command] == command[:name] }
|
|
261
|
-
# `from:` without a transition only guards on state; `to_state: nil` means it moves
|
|
262
|
-
# nothing.
|
|
263
|
-
if rows.empty?
|
|
264
|
-
froms = Array(command[:from]).compact.map(&:to_s)
|
|
265
|
-
return nil if froms.empty?
|
|
266
|
-
|
|
267
|
-
return { field: aggregate[:lifecycle][:field], to_state: nil, from_states: froms.uniq, unconstrained: false }
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
# A `from: nil` row is unconstrained and admits any state. It is carried as
|
|
271
|
-
# `unconstrained`
|
|
272
|
-
# rather than dropped: the caller also reads `to_state` to advance the lifecycle.
|
|
273
|
-
froms = rows.map { |r| r[:from_state] }
|
|
274
|
-
{
|
|
275
|
-
field: aggregate[:lifecycle][:field],
|
|
276
|
-
to_state: rows.first[:to_state],
|
|
277
|
-
from_states: froms.compact.uniq,
|
|
278
|
-
unconstrained: froms.any?(&:nil?)
|
|
279
|
-
}
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# Rust expression for a `:set` source, coerced to the target attribute's declared type.
|
|
283
|
-
# With `target_list:` a list source maps element-wise instead of unwrapping a single-field
|
|
284
|
-
# value object out of a `Vec<T>`.
|
|
285
|
-
def mutation_set_rhs(source, target_type, command, value_objects_by_name, target_list: false)
|
|
286
|
-
if source[:kind] == "literal"
|
|
287
|
-
return literal_rhs_for(source[:value], target_type, value_objects_by_name)
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# `state(:field)` clones the record's value; `pre` is the pre-dispatch state, so effect
|
|
291
|
-
# order is irrelevant.
|
|
292
|
-
return "pre.#{rust_ident_field(source[:name])}.clone()" if source[:kind] == "state"
|
|
293
|
-
|
|
294
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == source[:name] }
|
|
295
|
-
source_expr = "args.#{rust_ident_field(source[:name])}"
|
|
296
|
-
if target_list && source_attr[:list] && list_bridge_requires_element_mapping?(source_attr[:type], target_type)
|
|
297
|
-
return list_value_rhs(source_expr, source_attr[:type], target_type, value_objects_by_name)
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
value_rhs(source_expr, source_attr[:type], target_type, value_objects_by_name)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Problems with `state(:field)` sources: only a same-type, same-list-ness copy is generated.
|
|
304
|
-
# generator has no data for, and says so rather than guessing.
|
|
305
|
-
def state_source_problems(command, aggregate, value_objects_by_name)
|
|
306
|
-
command[:mutations].flat_map do |m|
|
|
307
|
-
case m[:op].to_s
|
|
308
|
-
when "set"
|
|
309
|
-
next [] unless m[:source][:kind] == "state"
|
|
310
|
-
[state_source_problem(m[:target], m[:source][:name], aggregate, aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s })].compact
|
|
311
|
-
when "append"
|
|
312
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
|
|
313
|
-
element = target_attr && append_element(aggregate, target_attr[:type], value_objects_by_name)
|
|
314
|
-
next [] unless element
|
|
315
|
-
m[:fields].filter_map do |field_name, source|
|
|
316
|
-
parsed = append_field_source(source)
|
|
317
|
-
next unless parsed.is_a?(Hecks::StateRef)
|
|
318
|
-
state_source_problem("#{m[:target]}.#{field_name}", parsed.name, aggregate, element[:attributes].find { |a| a[:name].to_s == field_name.to_s })
|
|
319
|
-
end
|
|
320
|
-
else
|
|
321
|
-
[]
|
|
322
|
-
end
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def state_source_problem(label, state_name, aggregate, target_attr)
|
|
327
|
-
state_attr = aggregate[:attributes].find { |a| a[:name].to_s == state_name.to_s }
|
|
328
|
-
return "#{label}: sources state(:#{state_name}), which #{aggregate[:name]} does not declare" unless state_attr
|
|
329
|
-
return "#{label}: no such target field" unless target_attr
|
|
330
|
-
same = state_attr[:type] == target_attr[:type] && !!state_attr[:list] == !!target_attr[:list]
|
|
331
|
-
"#{label}: state(:#{state_name}) is #{state_attr[:list] ? 'a list of ' : ''}#{state_attr[:type]}, the target wants #{target_attr[:list] ? 'a list of ' : ''}#{target_attr[:type]} — not generated yet" unless same
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
# One entry per `identified_by` component, as `{ expr:, param:, head: }`. A head that is
|
|
335
|
-
# "declared" reads `args.<head>` (walking a dotted rest); otherwise the caller supplies it
|
|
336
|
-
# as a
|
|
337
|
-
# `&str` parameter, and `head:` is the raw JSON key the router reads it under.
|
|
338
|
-
# Declared means a `:set` targets the head, or a same-named argument not claimed by an
|
|
339
|
-
# append
|
|
340
|
-
# supplies it (bare-name copy, `Governance::RoleAssignment.Assign`). A bare name shared with
|
|
341
|
-
# an
|
|
342
|
-
# appended attribute (`Aggregate.Attribute#name`) is not the owner's identity.
|
|
343
|
-
def identity_components(aggregate, command)
|
|
344
|
-
# Declared: a `:set` targets the head, or a same-named argument not claimed by an append.
|
|
345
|
-
append_claimed = Set.new
|
|
346
|
-
command[:mutations].each do |m|
|
|
347
|
-
next unless m[:op].to_s == "append"
|
|
348
|
-
|
|
349
|
-
Array(m[:fields]&.values).each do |v|
|
|
350
|
-
source = append_field_source(v)
|
|
351
|
-
append_claimed << source.to_s if source.is_a?(Symbol)
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
declared_names = command[:attributes].map { |a| a[:name].to_s }.to_set - append_claimed
|
|
355
|
-
|
|
356
|
-
aggregate[:identified_by].map do |path|
|
|
357
|
-
head, *rest = path.split(".")
|
|
358
|
-
set_target = command[:mutations].any? { |m| m[:op].to_s == "set" && m[:target].to_s == head }
|
|
359
|
-
if set_target || declared_names.include?(head)
|
|
360
|
-
if rest.any?
|
|
361
|
-
{ expr: "args.#{rust_ident_field(head)}.#{rest.map { |seg| rust_ident_field(seg) }.join('.')}.to_string()", param: nil }
|
|
362
|
-
else
|
|
363
|
-
{ expr: "args.#{rust_ident_field(head)}.to_string()", param: nil }
|
|
364
|
-
end
|
|
365
|
-
else
|
|
366
|
-
# `.to_string()` keeps this an owned `String`: a single-component identity is returned
|
|
367
|
-
# unwrapped into a `String` field, and this param is `&str`.
|
|
368
|
-
param = rust_ident_field(head)
|
|
369
|
-
{ expr: "#{param}.to_string()", param: "#{param}: &str", head: head }
|
|
370
|
-
end
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
def build_identity_expr(components)
|
|
375
|
-
return components.first[:expr] if components.size == 1
|
|
376
|
-
|
|
377
|
-
placeholders = components.map { "{}" }.join(":")
|
|
378
|
-
"format!(#{placeholders.inspect}, #{components.map { |c| c[:expr] }.join(', ')})"
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# Value of one `append` field: an argument runs through the `value_rhs` bridge, a literal
|
|
382
|
-
# is built inline. A caller-omittable argument is `Option<T>` in Rust, so it bridges through
|
|
383
|
-
# `optional_value_rhs`; `mark_append_optional_fields!` has already made the field match.
|
|
384
|
-
def append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate = nil)
|
|
385
|
-
parsed = append_field_source(source)
|
|
386
|
-
return state_field_rhs(parsed, field_attr, aggregate) if parsed.is_a?(Hecks::StateRef)
|
|
387
|
-
return literal_rhs_for(parsed, field_attr[:type], value_objects_by_name) unless parsed.is_a?(Symbol)
|
|
388
|
-
|
|
389
|
-
arg_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
|
|
390
|
-
arg_expr = "args.#{rust_ident_field(arg_attr[:name])}"
|
|
391
|
-
if arg_attr[:optional]
|
|
392
|
-
# Same representation on both sides: clone the `Option` as is. Only a cross-type
|
|
393
|
-
# coercion needs a per-element `.map`.
|
|
394
|
-
same_representation = arg_attr[:type] == field_attr[:type] ||
|
|
395
|
-
(effective_scalar_type(arg_attr[:type]) && effective_scalar_type(arg_attr[:type]) == effective_scalar_type(field_attr[:type]))
|
|
396
|
-
return "#{arg_expr}.clone()" if same_representation
|
|
397
|
-
|
|
398
|
-
return optional_value_rhs(arg_expr, arg_attr[:type], field_attr[:type], value_objects_by_name)
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
rhs = value_rhs(arg_expr, arg_attr[:type], field_attr[:type], value_objects_by_name)
|
|
402
|
-
# The struct field may be `Option<T>` because another command sources it optionally,
|
|
403
|
-
# so a required source still needs `Some(...)`.
|
|
404
|
-
field_attr[:optional] ? "Some(#{rhs})" : rhs
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
# Whether any effect reads the record's own state (`state(:field)` in a `set` or `append`);
|
|
408
|
-
# only then does the closure bind `pre`.
|
|
409
|
-
def reads_pre_state?(mutations)
|
|
410
|
-
mutations.any? do |m|
|
|
411
|
-
case m[:op].to_s
|
|
412
|
-
when "set" then m[:source][:kind] == "state"
|
|
413
|
-
when "append" then m[:fields].values.any? { |source| append_field_source(source).is_a?(Hecks::StateRef) }
|
|
414
|
-
else false
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
def pre_state_line = " let pre = record.clone();"
|
|
420
|
-
|
|
421
|
-
# An Integer effect that leaves signed 64-bit is a Fault, never a wrap or panic (C3.3).
|
|
422
|
-
# `amount` is bound once so the wording can quote it.
|
|
423
|
-
CHECKED_OPS = { "+" => "checked_add", "-" => "checked_sub", "*" => "checked_mul" }.freeze
|
|
424
|
-
|
|
425
|
-
def checked_arithmetic(op, field_ident, symbol, amount_expr)
|
|
426
|
-
"{ let amount = #{amount_expr}; current.#{field_ident}.#{CHECKED_OPS.fetch(symbol)}(amount)" \
|
|
427
|
-
".ok_or_else(|| crate::kernel::Refusal::Fault(format!(\"#{op} overflowed: {} #{symbol} {} does not fit in a " \
|
|
428
|
-
"64-bit integer\", current.#{field_ident}, amount)))? }"
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
# `state(:field)` into an appended element's field. Records `Option`-wrap non-list fields
|
|
432
|
-
# and
|
|
433
|
-
# elements don't, so scalars unwrap and lists clone as is.
|
|
434
|
-
def state_field_rhs(parsed, field_attr, aggregate)
|
|
435
|
-
expr = "pre.#{rust_ident_field(parsed.name)}.clone()"
|
|
436
|
-
state_attr = aggregate && aggregate[:attributes].find { |a| a[:name].to_s == parsed.name.to_s }
|
|
437
|
-
return expr if state_attr && state_attr[:list]
|
|
438
|
-
return expr if field_attr[:optional]
|
|
439
|
-
|
|
440
|
-
"#{expr}.unwrap()"
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
# The `value_rhs` bridge run against the value a `.map` closure unwraps from an `Option<T>`.
|
|
444
|
-
def optional_value_rhs(source_expr, source_type, target_type, value_objects_by_name)
|
|
445
|
-
"#{source_expr}.clone().map(|v| #{value_rhs('v', source_type, target_type, value_objects_by_name)})"
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
# Makes an appended element's field `Option<T>` when any command sources it from an optional
|
|
449
|
-
# argument, since Ruby stores the omitted nil without complaint. Mutates the shared
|
|
450
|
-
# attribute
|
|
451
|
-
# hash once, before the emitters read it, and never resets a `true`.
|
|
452
|
-
def mark_append_optional_fields!(aggregate, value_objects_by_name)
|
|
453
|
-
Hecks::RustBuild::AppendOptionals.mark_aggregate(aggregate, value_objects_by_name)
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
# Derives the mutations of a `corrects EVENT, reverses: true` command from the commands that
|
|
457
|
-
# emit EVENT: only increment/decrement invert (ADR 0041). Left alone when nothing emits the
|
|
458
|
-
# event
|
|
459
|
-
# or any sibling mutation can't invert, so `command_skip_reason` still refuses it.
|
|
460
|
-
INVERSE_MUTATION_OP = { "increment" => "decrement", "decrement" => "increment" }.freeze
|
|
461
|
-
|
|
462
|
-
def derive_reverses_mutations!(aggregate)
|
|
463
|
-
emitted_by = Hash.new { |hash, key| hash[key] = [] }
|
|
464
|
-
aggregate[:commands].each { |command| Array(command[:emits]).each { |event_name| emitted_by[event_name.to_s] << command } }
|
|
465
|
-
|
|
466
|
-
aggregate[:commands].each do |command|
|
|
467
|
-
correction = corrects_of(command)
|
|
468
|
-
next unless correction && corrects_reverses?(correction)
|
|
469
|
-
|
|
470
|
-
sources = emitted_by[correction[:target].to_s]
|
|
471
|
-
next if sources.empty?
|
|
472
|
-
|
|
473
|
-
derived = sources.flat_map { |c| c[:mutations] }.reject { |m| m[:op].to_s == "corrects" }
|
|
474
|
-
next if derived.empty? || derived.any? { |m| !INVERSE_MUTATION_OP.key?(m[:op].to_s) }
|
|
475
|
-
|
|
476
|
-
# Never derive twice onto a command that already has mutations, so re-runs stay
|
|
477
|
-
# idempotent.
|
|
478
|
-
# generator re-run over already-derived IR stays idempotent.
|
|
479
|
-
next if command[:mutations].any? { |m| m[:op].to_s != "corrects" }
|
|
480
|
-
|
|
481
|
-
derived.each do |m|
|
|
482
|
-
command[:mutations] << { op: INVERSE_MUTATION_OP.fetch(m[:op].to_s), target: m[:target], source: m[:source] }
|
|
483
|
-
end
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
# `optional:` is true for an aggregate record (non-list fields are `Option`-wrapped) and
|
|
488
|
-
# false for an entity element.
|
|
489
|
-
def emit_mutation_line(mutation, aggregate, command, value_objects_by_name, optional: true)
|
|
490
|
-
target_field = rust_ident_field(mutation[:target])
|
|
491
|
-
lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
|
|
492
|
-
|
|
493
|
-
# Callers join these lines with a newline, expecting each to carry its own 8-space indent.
|
|
494
|
-
" #{emit_mutation_line_body(mutation, aggregate, command, value_objects_by_name, target_field, lifecycle_field, optional)}"
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
def emit_mutation_line_body(mutation, aggregate, command, value_objects_by_name, target_field, lifecycle_field, optional)
|
|
498
|
-
case mutation[:op].to_s
|
|
499
|
-
when "append"
|
|
500
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
|
|
501
|
-
vo_type = rust_ident(target_attr[:type])
|
|
502
|
-
entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
|
|
503
|
-
element = entity || value_objects_by_name[target_attr[:type]]
|
|
504
|
-
|
|
505
|
-
fields_assignment = mutation[:fields].map do |field_name, source|
|
|
506
|
-
field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
|
|
507
|
-
"#{rust_ident_field(field_name)}: #{append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)}"
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
# An entity element also needs a minted identity and its lifecycle default; no
|
|
511
|
-
# `append:` names them.
|
|
512
|
-
collision_guard = ""
|
|
513
|
-
if entity
|
|
514
|
-
present = mutation[:fields].keys.map(&:to_s)
|
|
515
|
-
identified_by = Array(entity[:identified_by])
|
|
516
|
-
if identified_by.size > 1
|
|
517
|
-
# Composite identity: minting a partial key makes no sense, so every head must
|
|
518
|
-
# already be caller-supplied (as `Venue.AddLine`'s `append: { batch: :batch,
|
|
519
|
-
# sequence: :sequence }` does). Refuse a duplicate across the WHOLE tuple, the way
|
|
520
|
-
# `EntityElement.check_entity_collision`'s `heads.all?` does — this branch used to be
|
|
521
|
-
# skipped outright for any composite identity, so two elements sharing every identity
|
|
522
|
-
# component went through unrefused. Root cause of this bug.
|
|
523
|
-
heads = identified_by.map { |path| path.to_s.split(".").first }
|
|
524
|
-
if heads.all? { |head| present.include?(head) }
|
|
525
|
-
collision_guard = composite_append_collision_guard(heads, mutation[:fields], entity, command,
|
|
526
|
-
value_objects_by_name, aggregate, target_field,
|
|
527
|
-
identified_by)
|
|
528
|
-
end
|
|
529
|
-
else
|
|
530
|
-
id_attr, id_vo = entity_identity_mint(entity, value_objects_by_name)
|
|
531
|
-
if id_attr && !present.include?(id_attr[:name].to_s)
|
|
532
|
-
# One past the highest identity held, never `len() + 1`, which repeats after a
|
|
533
|
-
# shrink (C4.5).
|
|
534
|
-
id_field = rust_ident_field(id_attr[:name])
|
|
535
|
-
vo_field = rust_ident_field(id_vo[:attributes].first[:name])
|
|
536
|
-
mint = "#{rust_ident(id_attr[:type])} { #{vo_field}: record.#{target_field}.iter().map(|e| e.#{id_field}.#{vo_field}).max().unwrap_or(0) + 1 }"
|
|
537
|
-
fields_assignment << "#{rust_ident_field(id_attr[:name])}: #{mint}"
|
|
538
|
-
present << id_attr[:name].to_s
|
|
539
|
-
elsif id_attr
|
|
540
|
-
# Caller-supplied single-field identity: refuse a duplicate
|
|
541
|
-
# (`check_entity_collision`), matching rust/codegen byte for byte.
|
|
542
|
-
id_field = rust_ident_field(id_attr[:name])
|
|
543
|
-
_, source = mutation[:fields].find { |field_name, _| field_name.to_s == id_attr[:name].to_s }
|
|
544
|
-
field_attr = entity[:attributes].find { |a| a[:name].to_s == id_attr[:name].to_s }
|
|
545
|
-
id_rhs = append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)
|
|
546
|
-
entity_lit = entity[:name].to_s.inspect
|
|
547
|
-
aggregate_lit = aggregate[:name].to_s.inspect
|
|
548
|
-
identity_lit = identified_by.join(", ").inspect
|
|
549
|
-
# Single-field unwrap so `format!` prints the bare scalar; `id_vo` is non-nil here.
|
|
550
|
-
offered_field = rust_ident_field(id_vo[:attributes].first[:name])
|
|
551
|
-
offered_expr = "#{id_rhs}.#{offered_field}"
|
|
552
|
-
collision_guard =
|
|
553
|
-
"if record.#{target_field}.iter().any(|e| e.#{id_field} == #{id_rhs}) " \
|
|
554
|
-
"{ let offered = format!(\"{:?}\", #{offered_expr}); " \
|
|
555
|
-
"return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
|
|
556
|
-
"{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
|
|
557
|
-
"offered: &[offered.as_str()] }.render_args())); }\n "
|
|
558
|
-
end
|
|
559
|
-
end
|
|
560
|
-
if entity[:lifecycle] && !present.include?(entity[:lifecycle][:field].to_s)
|
|
561
|
-
fields_assignment << "#{rust_ident_field(entity[:lifecycle][:field])}: #{entity[:lifecycle][:default].inspect}.to_string()"
|
|
562
|
-
present << entity[:lifecycle][:field].to_s
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
# A list attribute no `append:` binding names starts empty. Ruby's fields Hash just
|
|
566
|
-
# lacks the
|
|
567
|
-
# key, but a Rust struct literal must assign every field.
|
|
568
|
-
entity[:attributes].each do |attr|
|
|
569
|
-
next unless attr[:list]
|
|
570
|
-
next if present.include?(attr[:name].to_s)
|
|
571
|
-
|
|
572
|
-
fields_assignment << "#{rust_ident_field(attr[:name])}: Vec::new()"
|
|
573
|
-
present << attr[:name].to_s
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
# An optional scalar attribute is `Option<T>` in Rust, so an unmentioned one defaults
|
|
577
|
-
# to `None`.
|
|
578
|
-
entity[:attributes].each do |attr|
|
|
579
|
-
next if attr[:list] || !attr[:optional]
|
|
580
|
-
next if present.include?(attr[:name].to_s)
|
|
581
|
-
|
|
582
|
-
fields_assignment << "#{rust_ident_field(attr[:name])}: None"
|
|
583
|
-
present << attr[:name].to_s
|
|
584
|
-
end
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
"#{collision_guard}#{Exemplar.render(
|
|
588
|
-
"mutation_append",
|
|
589
|
-
"tmpl_field" => target_field,
|
|
590
|
-
"tmpl_fields_placeholder()" => "#{vo_type} { #{fields_assignment.join(', ')} }"
|
|
591
|
-
)}"
|
|
592
|
-
when "set"
|
|
593
|
-
if mutation[:target].to_s == lifecycle_field
|
|
594
|
-
rhs = mutation_set_rhs(mutation[:source], "String", command, value_objects_by_name)
|
|
595
|
-
Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
|
|
596
|
-
else
|
|
597
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
|
|
598
|
-
rhs = mutation_set_rhs(mutation[:source], target_attr[:type], command, value_objects_by_name, target_list: !!target_attr[:list])
|
|
599
|
-
source_attr = mutation[:source][:kind] == "argument" ? command[:attributes].find { |a| a[:name].to_s == mutation[:source][:name].to_s } : nil
|
|
600
|
-
|
|
601
|
-
if target_attr[:list] && optional && list_attr_creation_optional?(aggregate, target_attr[:name], value_objects_by_name)
|
|
602
|
-
# `CardPayment.Authorize`'s redundant `sets :tags`: `list_attr_creation_optional?`
|
|
603
|
-
# already
|
|
604
|
-
# Option-wrapped this field, so the value is assigned straight across.
|
|
605
|
-
Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
|
|
606
|
-
elsif target_attr[:list] && source_attr && source_attr[:optional]
|
|
607
|
-
# A record's list field is a plain `Vec<T>` here; an optional source unwraps with a
|
|
608
|
-
# `[]` fallback.
|
|
609
|
-
Exemplar.render("mutation_set_unwrap_or_default", "tmpl_field" => target_field, "tmpl_optional_rhs_placeholder()" => rhs)
|
|
610
|
-
elsif target_attr[:list]
|
|
611
|
-
# Duplicate-identity guard for a whole-list replace of entities
|
|
612
|
-
# (`Ledger.ReplaceEntries`).
|
|
613
|
-
guard, effective_rhs = entity_list_replace_guard(aggregate, target_attr, target_field, rhs, value_objects_by_name)
|
|
614
|
-
"#{guard}#{Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => effective_rhs)}"
|
|
615
|
-
else
|
|
616
|
-
# `wrap` for a per-field `Option<T>` target or a whole-record wrap, unless the
|
|
617
|
-
# source argument
|
|
618
|
-
# is already `Option<T>` (wrapping again would give `Option<Option<T>>`).
|
|
619
|
-
wrap = (optional || target_attr[:optional]) && !(source_attr && source_attr[:optional])
|
|
620
|
-
if wrap
|
|
621
|
-
Exemplar.render("mutation_set_wrapped", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
|
|
622
|
-
else
|
|
623
|
-
Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
|
|
624
|
-
end
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
when "increment", "decrement"
|
|
628
|
-
target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
|
|
629
|
-
vo_type = rust_ident(target_attr[:type])
|
|
630
|
-
field_ident = rust_ident_field(integer_field)
|
|
631
|
-
amount_expr = arithmetic_amount_expr(mutation[:source], command, value_objects_by_name, integer_field)
|
|
632
|
-
# The sign comes from the IR's `sign` field (`Bluebook::Mutation.sign_for`), not the op
|
|
633
|
-
# name.
|
|
634
|
-
sign = mutation[:sign].to_s == "1" ? "+" : "-"
|
|
635
|
-
current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
|
|
636
|
-
updated = "#{vo_type} { #{field_ident}: #{checked_arithmetic(mutation[:op].to_s, field_ident, sign, amount_expr)}, ..current }"
|
|
637
|
-
Exemplar.render(
|
|
638
|
-
"mutation_arithmetic",
|
|
639
|
-
"tmpl_field" => target_field,
|
|
640
|
-
"tmpl_current_placeholder()" => current,
|
|
641
|
-
"tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
|
|
642
|
-
)
|
|
643
|
-
when "multiply"
|
|
644
|
-
# `multiply` shares increment/decrement's Integer-only target and amount pairing but has
|
|
645
|
-
# no
|
|
646
|
-
# `sign`; Float value objects are not reachable here.
|
|
647
|
-
target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
|
|
648
|
-
vo_type = rust_ident(target_attr[:type])
|
|
649
|
-
field_ident = rust_ident_field(integer_field)
|
|
650
|
-
amount_expr = arithmetic_amount_expr(mutation[:source], command, value_objects_by_name, integer_field)
|
|
651
|
-
current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
|
|
652
|
-
updated = "#{vo_type} { #{field_ident}: #{checked_arithmetic('multiply', field_ident, '*', amount_expr)}, ..current }"
|
|
653
|
-
Exemplar.render(
|
|
654
|
-
"mutation_arithmetic",
|
|
655
|
-
"tmpl_field" => target_field,
|
|
656
|
-
"tmpl_current_placeholder()" => current,
|
|
657
|
-
"tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
|
|
658
|
-
)
|
|
659
|
-
when "clamp"
|
|
660
|
-
# `clamp` bounds the current value into a literal `[min, max]`, so there is no amount
|
|
661
|
-
# argument.
|
|
662
|
-
# `i64::clamp` matches Ruby's `Integer#clamp`.
|
|
663
|
-
target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
|
|
664
|
-
vo_type = rust_ident(target_attr[:type])
|
|
665
|
-
field_ident = rust_ident_field(integer_field)
|
|
666
|
-
min, max = clamp_bounds_ints(mutation[:source])
|
|
667
|
-
current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
|
|
668
|
-
updated = "#{vo_type} { #{field_ident}: current.#{field_ident}.clamp(#{min}, #{max}), ..current }"
|
|
669
|
-
Exemplar.render(
|
|
670
|
-
"mutation_arithmetic",
|
|
671
|
-
"tmpl_field" => target_field,
|
|
672
|
-
"tmpl_current_placeholder()" => current,
|
|
673
|
-
"tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
|
|
674
|
-
)
|
|
675
|
-
when "remove"
|
|
676
|
-
# `remove:` on an entity-typed list matches by identity field (`remove_field_problems`
|
|
677
|
-
# already
|
|
678
|
-
# confirmed one exists); `retain` keeps every element whose identity differs.
|
|
679
|
-
target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
|
|
680
|
-
entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
|
|
681
|
-
id_attr, = entity_identity_mint(entity, value_objects_by_name)
|
|
682
|
-
id_field = rust_ident_field(id_attr[:name])
|
|
683
|
-
source_attr = command[:attributes].find { |a| a[:name].to_s == mutation[:source][:name].to_s }
|
|
684
|
-
match_expr = value_rhs("args.#{rust_ident_field(source_attr[:name])}", source_attr[:type],
|
|
685
|
-
id_attr[:type], value_objects_by_name)
|
|
686
|
-
Exemplar.render(
|
|
687
|
-
"mutation_remove",
|
|
688
|
-
"tmpl_field" => target_field,
|
|
689
|
-
"tmpl_id_field" => id_field,
|
|
690
|
-
"tmpl_remove_match_placeholder()" => match_expr
|
|
691
|
-
)
|
|
692
|
-
end
|
|
693
|
-
end
|
|
694
|
-
end
|
|
695
|
-
end
|