hecks 4.0.0 → 5.0.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/exe/hecks +1 -1
- data/lib/hecks/adapters/driving/json.rb +8 -9
- data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
- data/lib/hecks/forms/app/responses.rb +3 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
- data/lib/hecks/runtime/value/unwrapping.rb +60 -0
- data/lib/hecks/runtime/value.rb +3 -27
- data/lib/hecks/version.rb +1 -1
- data/rust/codegen/src/domain_generator.rs +6 -0
- data/rust/codegen/src/main.rs +6 -0
- data/rust/codegen/src/presenters.rs +335 -0
- data/rust/codegen/src/read_models.rs +1 -57
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +47 -0
- data/rust/host/src/server.rs +76 -4
- data/rust/src/kernel/cli.rs +14 -1
- data/rust/src/kernel/json.rs +15 -1
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/present.rs +261 -0
- metadata +7 -4
- /data/prebuilt/{bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
- /data/prebuilt/{02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
- /data/prebuilt/{verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a6fed3e5b894585b2fa7253696873a4218980f6982b4afeeda5effdd5a4aba4
|
|
4
|
+
data.tar.gz: b29a6ce60d5ae9142f0b53ee062979397f3c1384ce2f6da8c70b14396425c989
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77b6974a39536f06fd5bffeb121899ca35b1563e5db172bac48dfeef426c445ed459bea60bc004ea29fdb14b14149a72467b61dddce02aa1cc61b2bb9c11b944
|
|
7
|
+
data.tar.gz: 56fd3680ca94d0060d0ff5e1324c3ccbefb03f5dfc4ac03760cdfb8ee6ad2081c70cef85e4161a66098acb396bdb17c2a485185ff473bee49546a9f0a7336d65
|
data/exe/hecks
CHANGED
|
@@ -83,20 +83,19 @@ module Hecks
|
|
|
83
83
|
# with Symbol keys; a leaf is returned as it came
|
|
84
84
|
def deep_symbolize(value) = Runtime::Value.deep_symbolize(value)
|
|
85
85
|
|
|
86
|
-
# Unwraps a record, a query row, or any value holding `Runtime::Value`s into plain
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# `Handle`, which `materialize` does not recognize.
|
|
86
|
+
# Unwraps a record, a query row, or any value holding `Runtime::Value`s into the plain
|
|
87
|
+
# data a caller is answered with. A single-field value object answers as its bare
|
|
88
|
+
# field (`name: "Margherita"`), the shape a command takes it in; stored state stays
|
|
89
|
+
# wrapped.
|
|
91
90
|
#
|
|
92
91
|
# @param value [Adapters::Driving::Handle, Runtime::Value, Hash, Array, Object] what to
|
|
93
|
-
# unwrap;
|
|
94
|
-
# a `Handle` is read through its `to_h`, so its `:id` comes along
|
|
92
|
+
# unwrap; a `Handle` is read through its `to_h`, so its `:id` comes along
|
|
95
93
|
# @return [Hash, Array, Object] the same data with every `Runtime::Value` replaced
|
|
96
|
-
# by a Hash of its fields
|
|
94
|
+
# by its sole field's value, or by a Hash of its fields when it has more than one;
|
|
95
|
+
# any other value is returned as it came
|
|
97
96
|
def materialize(value)
|
|
98
97
|
value = value.to_h if value.is_a?(Handle)
|
|
99
|
-
Runtime::Value.
|
|
98
|
+
Runtime::Value.materialize_unwrapped(value)
|
|
100
99
|
end
|
|
101
100
|
|
|
102
101
|
# Turns a command's JSON body, raw or already parsed, into the `to:`/`with:`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../page"
|
|
3
|
+
require_relative "../../runtime/value"
|
|
3
4
|
|
|
4
5
|
module Hecks
|
|
5
6
|
module Forms
|
|
@@ -13,8 +14,9 @@ module Hecks
|
|
|
13
14
|
respond(status, "text/html; charset=utf-8", Page.render(title: title, body: body, breadcrumbs: breadcrumbs))
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
# A single-field value object answers as its bare field, as `Json.materialize` does.
|
|
16
18
|
def json(status, payload)
|
|
17
|
-
respond(status, "application/json", JSON.pretty_generate(payload))
|
|
19
|
+
respond(status, "application/json", JSON.pretty_generate(Runtime::Value.materialize_unwrapped(payload)))
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def redirect(location)
|
|
@@ -57,7 +57,7 @@ module Hecks
|
|
|
57
57
|
provider = membership_provider
|
|
58
58
|
verbs, gate, people = check_unadministered(provider)
|
|
59
59
|
|
|
60
|
-
admitted = people.none? { |person| person
|
|
60
|
+
admitted = people.none? { |person| person[:email] == email }
|
|
61
61
|
admit(provider, verbs.fetch(:admit), email, name || email[/\A[^@]+/]) if admitted
|
|
62
62
|
grant(provider, verbs.fetch(:grant), email, role || gate)
|
|
63
63
|
Result.new(email: email, role: role || gate, admitted: admitted)
|
|
@@ -103,10 +103,10 @@ module Hecks
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def refuse_if_administered!(people, roles)
|
|
106
|
-
held = people.select { |person| roles.map(&:downcase).include?(person
|
|
106
|
+
held = people.select { |person| roles.map(&:downcase).include?(person[:role].to_s.downcase) }
|
|
107
107
|
return if held.empty?
|
|
108
108
|
|
|
109
|
-
who = held.map { |person| person
|
|
109
|
+
who = held.map { |person| person[:email] }.join(", ")
|
|
110
110
|
raise Runtime::NotFound, "an administrator already exists (#{who}); grant roles through the " \
|
|
111
111
|
"domain's own #{roles.first} access instead"
|
|
112
112
|
end
|
|
@@ -23,9 +23,14 @@ export function hostUrl(options: HostOptions = {}): string {
|
|
|
23
23
|
return (options.url ?? (process.env[EDITOR.hostEnv] || EDITOR.hostDefault)).replace(/\/+$/, "");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* A client for the domain this editor edits. It reads and writes the stored shape (`bare: false`),
|
|
28
|
+
* since its forms walk every value object by the schema's own fields.
|
|
29
|
+
*/
|
|
27
30
|
export function hostClient(options: HostOptions = {}): HostClient {
|
|
28
|
-
return new HostClient({
|
|
31
|
+
return new HostClient({
|
|
32
|
+
domain: options.domain ?? SCHEMA.domain, url: hostUrl(options), fetch: options.fetch, timeoutMs: EDITOR.timeoutMs, bare: false,
|
|
33
|
+
});
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Runtime
|
|
3
|
+
class Value
|
|
4
|
+
# The shape a caller is answered with: a single-attribute value object as its bare field,
|
|
5
|
+
# following the declared types down through nested value objects and lists. Extended into
|
|
6
|
+
# `Value` beside `Coercion`; `materialize` keeps the wrapped shape stored state depends on.
|
|
7
|
+
module Unwrapping
|
|
8
|
+
# `materialize`, but a single-attribute value object unwraps to its bare field. What
|
|
9
|
+
# `group_by` rows and `Driving::Json.materialize` answer with.
|
|
10
|
+
#
|
|
11
|
+
# @param value [Object] the value to materialize; anything that is not a `Runtime::Value`,
|
|
12
|
+
# Array or Hash passes through unchanged
|
|
13
|
+
# @return [Object] `value` with every nested `Runtime::Value` replaced by its own sole
|
|
14
|
+
# field's value (recursively unwrapped), or by its own `Hash` of fields when it has
|
|
15
|
+
# more than one
|
|
16
|
+
def materialize_unwrapped(value)
|
|
17
|
+
case value
|
|
18
|
+
when self then unwrap_typed(value.value_object, value.raw_fields)
|
|
19
|
+
when Array then value.map { |item| materialize_unwrapped(item) }
|
|
20
|
+
when Hash then value.transform_values { |item| materialize_unwrapped(item) }
|
|
21
|
+
else value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# A value object's fields by its declared attributes. A nested value object is often held
|
|
28
|
+
# as a plain Hash, so its own type is looked up rather than read off the field.
|
|
29
|
+
def unwrap_typed(value_object, fields)
|
|
30
|
+
sole = value_object.sole_attribute
|
|
31
|
+
return unwrap_field(value_object, sole, fields[sole.name]) if sole
|
|
32
|
+
|
|
33
|
+
value_object.attributes.to_h { |attr| [attr.name, unwrap_field(value_object, attr, fields[attr.name])] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unwrap_field(value_object, attribute, held)
|
|
37
|
+
case held
|
|
38
|
+
when Array then held.map { |item| unwrap_field(value_object, attribute, item) }
|
|
39
|
+
when Hash then unwrap_held_hash(value_object, attribute, held)
|
|
40
|
+
else materialize_unwrapped(held)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def unwrap_held_hash(value_object, attribute, held)
|
|
45
|
+
nested = nested_value_object(value_object, attribute.type)
|
|
46
|
+
return materialize_unwrapped(held) unless nested
|
|
47
|
+
|
|
48
|
+
unwrap_typed(nested, held.transform_keys(&:to_sym))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The value object a field's type names, resolved against the aggregate that declares
|
|
52
|
+
# `value_object`; nil for a primitive, or when the owner is not known.
|
|
53
|
+
def nested_value_object(value_object, type)
|
|
54
|
+
owner = value_object.respond_to?(:hecks_owner) ? value_object.hecks_owner : nil
|
|
55
|
+
owner.respond_to?(:value_object) ? value_object_for(owner, type) : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/hecks/runtime/value.rb
CHANGED
|
@@ -9,12 +9,13 @@ require_relative "value/flags"
|
|
|
9
9
|
require_relative "value/identifiers"
|
|
10
10
|
require_relative "value/entity_list_coercion"
|
|
11
11
|
require_relative "value/admission"
|
|
12
|
+
require_relative "value/unwrapping"
|
|
12
13
|
|
|
13
14
|
module Hecks
|
|
14
15
|
module Runtime
|
|
15
16
|
# A typed value object: frozen fields, read by name.
|
|
16
17
|
# Construction lives in value/coercion.rb, field_checks.rb, entity_list_coercion.rb and
|
|
17
|
-
# admission.rb.
|
|
18
|
+
# admission.rb; the shape a caller is answered with in unwrapping.rb.
|
|
18
19
|
class Value
|
|
19
20
|
extend FieldChecks
|
|
20
21
|
extend Coercion
|
|
@@ -24,6 +25,7 @@ module Hecks
|
|
|
24
25
|
extend Identifiers
|
|
25
26
|
extend EntityListCoercion
|
|
26
27
|
extend Admission
|
|
28
|
+
extend Unwrapping
|
|
27
29
|
|
|
28
30
|
attr_reader :value_object
|
|
29
31
|
|
|
@@ -115,32 +117,6 @@ module Hecks
|
|
|
115
117
|
end
|
|
116
118
|
end
|
|
117
119
|
|
|
118
|
-
# `materialize`, but a single-attribute value object unwraps to its bare field.
|
|
119
|
-
# Opt-in for `group_by` rows; `materialize` keeps the wrapped shape callers depend on.
|
|
120
|
-
#
|
|
121
|
-
# @param value [Object] the value to materialize; anything that is not a `Runtime::Value`,
|
|
122
|
-
# Array or Hash passes through unchanged
|
|
123
|
-
# @return [Object] `value` with every nested `Runtime::Value` replaced by its own sole
|
|
124
|
-
# field's value (recursively unwrapped), or by its own `Hash` of fields when it has
|
|
125
|
-
# more than one
|
|
126
|
-
def self.materialize_unwrapped(value)
|
|
127
|
-
case value
|
|
128
|
-
when self then unwrap_value_object(value)
|
|
129
|
-
when Array then value.map { |item| materialize_unwrapped(item) }
|
|
130
|
-
when Hash then value.transform_values { |item| materialize_unwrapped(item) }
|
|
131
|
-
else value
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def self.unwrap_value_object(value)
|
|
136
|
-
sole = value.value_object.sole_attribute
|
|
137
|
-
return materialize_unwrapped(value[sole.name]) if sole
|
|
138
|
-
|
|
139
|
-
# Not `value.to_h`: it materializes nested values to Hashes, ending the recursion.
|
|
140
|
-
value.value_object.attributes.to_h { |attr| [attr.name, materialize_unwrapped(value[attr.name])] }
|
|
141
|
-
end
|
|
142
|
-
private_class_method :unwrap_value_object
|
|
143
|
-
|
|
144
120
|
# Reduces an append-only `list_of` sub-log to its current state: the latest row per `key`.
|
|
145
121
|
# Grouping only; what counts as removed, and the ordering, belong to the caller.
|
|
146
122
|
#
|
data/lib/hecks/version.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
# Read directly by the gemspec, so this file must never require the
|
|
3
3
|
# rest of the gem (its dependencies aren't resolved yet at that point).
|
|
4
|
-
VERSION = "
|
|
4
|
+
VERSION = "5.0.0".freeze # rust/host/HECKS_RELEASE names the same release
|
|
5
5
|
end
|
|
@@ -1116,6 +1116,12 @@ pub fn generate(
|
|
|
1116
1116
|
}
|
|
1117
1117
|
}
|
|
1118
1118
|
|
|
1119
|
+
puts_blank(&mut out);
|
|
1120
|
+
puts_str(
|
|
1121
|
+
&mut out,
|
|
1122
|
+
&crate::presenters::emit_aggregate_presenter(aggregate, &value_objects_by_name),
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1119
1125
|
aggregate_files.push(GeneratedFile {
|
|
1120
1126
|
name: format!("{}.rs", crate::naming::aggregate_module(agg_name)),
|
|
1121
1127
|
content: out,
|
data/rust/codegen/src/main.rs
CHANGED
|
@@ -26,6 +26,7 @@ mod naming;
|
|
|
26
26
|
mod optional_pass;
|
|
27
27
|
mod ports;
|
|
28
28
|
mod prelude;
|
|
29
|
+
mod presenters;
|
|
29
30
|
mod queries;
|
|
30
31
|
mod reactions;
|
|
31
32
|
mod read_models;
|
|
@@ -377,6 +378,11 @@ fn run_full(args: &[String]) -> Result<(), String> {
|
|
|
377
378
|
&mut merged_rs,
|
|
378
379
|
&read_models::emit_read_model_table(&ex, &merged_read_models),
|
|
379
380
|
);
|
|
381
|
+
puts_blank(&mut merged_rs);
|
|
382
|
+
puts_str(
|
|
383
|
+
&mut merged_rs,
|
|
384
|
+
&presenters::emit_present_router(&merged_aggregates),
|
|
385
|
+
);
|
|
380
386
|
|
|
381
387
|
let merged_path = format!("{target_out_dir}/merged.rs");
|
|
382
388
|
std::fs::write(&merged_path, &merged_rs).map_err(|e| format!("writing {merged_path}: {e}"))?;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
//! The bare answer shape, generated: a port of Ruby's `Value.materialize_unwrapped`, emitted as
|
|
2
|
+
//! type-directed Rust over `crate::kernel::Json`.
|
|
3
|
+
//!
|
|
4
|
+
//! A single-attribute value object answers as its sole field's value, a multi-field one as an
|
|
5
|
+
//! object of its fields, each unwrapped the same way; a list maps item-wise and an entity's
|
|
6
|
+
//! fields are unwrapped. Stored state stays wrapped: this is only what a caller is answered with
|
|
7
|
+
//! (`group_by` rows, and the kernel's `{"present": answer}` input).
|
|
8
|
+
|
|
9
|
+
use crate::json::Json;
|
|
10
|
+
use crate::registry::AggregateEntry;
|
|
11
|
+
use std::collections::HashMap;
|
|
12
|
+
|
|
13
|
+
/// Emits an expression unwrapping the `Json` named `expr` as a `type_name` (a `list` of them
|
|
14
|
+
/// when `list`), resolving value objects in `value_objects_by_name` and entities in `aggregate`.
|
|
15
|
+
pub fn unwrap_json_expr(
|
|
16
|
+
expr: &str,
|
|
17
|
+
type_name: &str,
|
|
18
|
+
list: bool,
|
|
19
|
+
aggregate: &Json,
|
|
20
|
+
value_objects_by_name: &HashMap<String, &Json>,
|
|
21
|
+
) -> String {
|
|
22
|
+
Unwrapper {
|
|
23
|
+
aggregate,
|
|
24
|
+
value_objects_by_name,
|
|
25
|
+
}
|
|
26
|
+
.expr(expr, type_name, list, &mut Vec::new())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Emits `pub fn present(state) -> Json` for one aggregate's module: its state (or one of its
|
|
30
|
+
/// events' payloads) with every field it declares unwrapped, other keys passed through.
|
|
31
|
+
///
|
|
32
|
+
/// The fields are the aggregate's attributes and projected fields, then every aggregate and
|
|
33
|
+
/// entity command argument not already named, since an event payload carries a command's
|
|
34
|
+
/// arguments. A name declared with more than one type unwraps by whichever type's field names
|
|
35
|
+
/// the value's own keys fit, first declared first.
|
|
36
|
+
pub fn emit_aggregate_presenter(
|
|
37
|
+
aggregate: &Json,
|
|
38
|
+
value_objects_by_name: &HashMap<String, &Json>,
|
|
39
|
+
) -> String {
|
|
40
|
+
let unwrapper = Unwrapper {
|
|
41
|
+
aggregate,
|
|
42
|
+
value_objects_by_name,
|
|
43
|
+
};
|
|
44
|
+
let arms: Vec<String> = presented_fields(aggregate)
|
|
45
|
+
.iter()
|
|
46
|
+
.map(|(name, candidates)| format!("{name:?} => {},", unwrapper.candidates("v", candidates)))
|
|
47
|
+
.collect();
|
|
48
|
+
if arms.is_empty() {
|
|
49
|
+
return format!("{PRESENT_DOC}pub fn present(state: crate::kernel::Json) -> crate::kernel::Json {{\n state\n}}\n");
|
|
50
|
+
}
|
|
51
|
+
let arms = arms.join(" ");
|
|
52
|
+
format!(
|
|
53
|
+
"{PRESENT_DOC}pub fn present(state: crate::kernel::Json) -> crate::kernel::Json {{\n match state {{ crate::kernel::Json::Object(fields) => crate::kernel::Json::Object(fields.into_iter().map(|(k, v)| {{ let new_v = match k.as_str() {{ {arms} _ => v }}; (k, new_v) }}).collect()), other => other }}\n}}\n"
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PRESENT_DOC: &str = "/// This aggregate's state, or one of its events' payloads, as a caller is answered with\n/// it: each single-field value object as its bare field (the shape a command takes it in).\n";
|
|
58
|
+
|
|
59
|
+
/// Emits the merged target's `present(aggregate, state)`: one arm per generated aggregate across
|
|
60
|
+
/// every chapter, keyed by its qualified `Domain::Aggregate` name; any other name passes through.
|
|
61
|
+
pub fn emit_present_router(aggregates: &[AggregateEntry]) -> String {
|
|
62
|
+
let arms: Vec<String> = aggregates
|
|
63
|
+
.iter()
|
|
64
|
+
.map(|a| {
|
|
65
|
+
format!(
|
|
66
|
+
" {} => crate::generated::{}::{}::present(state),",
|
|
67
|
+
crate::naming::ruby_inspect_string(&format!("{}::{}", a.domain_name, a.name)),
|
|
68
|
+
a.chapter_mod,
|
|
69
|
+
a.module_name
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
.collect();
|
|
73
|
+
format!(
|
|
74
|
+
"/// One `\"Domain::Aggregate\"` state as a caller is answered with it (the kernel's\n/// `{{\"present\": answer}}` input); stored state stays wrapped. An unknown name passes through.\npub fn present(aggregate: &str, state: crate::kernel::Json) -> crate::kernel::Json {{\n match aggregate {{\n{}\n _ => state,\n }}\n}}\n",
|
|
75
|
+
arms.join("\n")
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Each presented name with its distinct declared (type, list) pairs, in declaration order.
|
|
80
|
+
fn presented_fields(aggregate: &Json) -> Vec<(String, Vec<(String, bool)>)> {
|
|
81
|
+
let mut attributes: Vec<Json> = aggregate
|
|
82
|
+
.get("attributes")
|
|
83
|
+
.map_or_default(Json::each)
|
|
84
|
+
.to_vec();
|
|
85
|
+
attributes.extend(crate::types::projected_field_pseudo_attributes(aggregate));
|
|
86
|
+
let commands = aggregate
|
|
87
|
+
.get("commands")
|
|
88
|
+
.map_or_default(Json::each)
|
|
89
|
+
.iter()
|
|
90
|
+
.chain(
|
|
91
|
+
aggregate
|
|
92
|
+
.get("entities")
|
|
93
|
+
.map_or_default(Json::each)
|
|
94
|
+
.iter()
|
|
95
|
+
.flat_map(|e| e.get("commands").map_or_default(Json::each)),
|
|
96
|
+
);
|
|
97
|
+
for command in commands {
|
|
98
|
+
attributes.extend(
|
|
99
|
+
command
|
|
100
|
+
.get("attributes")
|
|
101
|
+
.map_or_default(Json::each)
|
|
102
|
+
.iter()
|
|
103
|
+
.cloned(),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let mut fields: Vec<(String, Vec<(String, bool)>)> = Vec::new();
|
|
108
|
+
for attribute in &attributes {
|
|
109
|
+
let name = crate::attr::name(attribute).to_string();
|
|
110
|
+
let candidate = (
|
|
111
|
+
crate::attr::type_name(attribute).to_string(),
|
|
112
|
+
crate::attr::list(attribute),
|
|
113
|
+
);
|
|
114
|
+
match fields.iter_mut().find(|(n, _)| *n == name) {
|
|
115
|
+
Some((_, candidates)) if !candidates.contains(&candidate) => candidates.push(candidate),
|
|
116
|
+
Some(_) => {}
|
|
117
|
+
None => fields.push((name, vec![candidate])),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
fields
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
struct Unwrapper<'a> {
|
|
124
|
+
aggregate: &'a Json,
|
|
125
|
+
value_objects_by_name: &'a HashMap<String, &'a Json>,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
impl Unwrapper<'_> {
|
|
129
|
+
// The declared fields of a value object or entity type, and whether it is a value object.
|
|
130
|
+
fn fields_of(&self, type_name: &str) -> Option<(&[Json], bool)> {
|
|
131
|
+
if let Some(vo) = self.value_objects_by_name.get(type_name) {
|
|
132
|
+
return vo.get("attributes").map(|a| (Json::each(a), true));
|
|
133
|
+
}
|
|
134
|
+
self.aggregate
|
|
135
|
+
.get("entities")
|
|
136
|
+
.map_or_default(Json::each)
|
|
137
|
+
.iter()
|
|
138
|
+
.find(|e| e.get("name").map(Json::to_s).unwrap_or_default() == type_name)
|
|
139
|
+
.and_then(|e| e.get("attributes").map(|a| (Json::each(a), false)))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `seen` holds the types being expanded, so a type that contains itself stops there and
|
|
143
|
+
// passes its nested value through rather than recursing without end.
|
|
144
|
+
fn expr(&self, expr: &str, type_name: &str, list: bool, seen: &mut Vec<String>) -> String {
|
|
145
|
+
if list {
|
|
146
|
+
let inner = self.expr("item", type_name, false, seen);
|
|
147
|
+
return format!("match {expr} {{ crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| {inner}).collect()), other => other }}");
|
|
148
|
+
}
|
|
149
|
+
let Some((fields_meta, is_value_object)) = self.fields_of(type_name) else {
|
|
150
|
+
return expr.to_string();
|
|
151
|
+
};
|
|
152
|
+
if seen.iter().any(|s| s == type_name) {
|
|
153
|
+
return expr.to_string();
|
|
154
|
+
}
|
|
155
|
+
seen.push(type_name.to_string());
|
|
156
|
+
let out = if is_value_object && fields_meta.len() == 1 {
|
|
157
|
+
let field = &fields_meta[0];
|
|
158
|
+
let unwrapped = self.expr(
|
|
159
|
+
"field_value",
|
|
160
|
+
crate::attr::type_name(field),
|
|
161
|
+
crate::attr::list(field),
|
|
162
|
+
seen,
|
|
163
|
+
);
|
|
164
|
+
let field_name = crate::attr::name(field);
|
|
165
|
+
format!("match {expr} {{ crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == {field_name:?}).map_or(crate::kernel::Json::Null, |(_, field_value)| {unwrapped}), other => other }}")
|
|
166
|
+
} else {
|
|
167
|
+
let inner_arms: Vec<String> = fields_meta
|
|
168
|
+
.iter()
|
|
169
|
+
.map(|f| {
|
|
170
|
+
let unwrapped =
|
|
171
|
+
self.expr("v", crate::attr::type_name(f), crate::attr::list(f), seen);
|
|
172
|
+
format!("{:?} => {unwrapped},", crate::attr::name(f))
|
|
173
|
+
})
|
|
174
|
+
.collect();
|
|
175
|
+
let inner_arms = inner_arms.join(" ");
|
|
176
|
+
format!("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 }}")
|
|
177
|
+
};
|
|
178
|
+
seen.pop();
|
|
179
|
+
out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// One declared type unwraps unconditionally; several are told apart by the value's keys.
|
|
183
|
+
fn candidates(&self, expr: &str, candidates: &[(String, bool)]) -> String {
|
|
184
|
+
if let [(type_name, list)] = candidates {
|
|
185
|
+
return self.expr(expr, type_name, *list, &mut Vec::new());
|
|
186
|
+
}
|
|
187
|
+
let branches: Vec<String> = candidates
|
|
188
|
+
.iter()
|
|
189
|
+
.filter_map(|(type_name, list)| {
|
|
190
|
+
let guard = self.fits(&format!("&{expr}"), type_name, *list)?;
|
|
191
|
+
let unwrapped = self.expr(expr, type_name, *list, &mut Vec::new());
|
|
192
|
+
Some(format!("if {guard} {{ {unwrapped} }}"))
|
|
193
|
+
})
|
|
194
|
+
.collect();
|
|
195
|
+
if branches.is_empty() {
|
|
196
|
+
return expr.to_string();
|
|
197
|
+
}
|
|
198
|
+
format!("{} else {{ {expr} }}", branches.join(" else "))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Whether the value at `expr` (a `&Json`) has the shape of `type_name`: an object whose keys
|
|
202
|
+
// are all its declared fields. `None` for a type with no fields to unwrap.
|
|
203
|
+
fn fits(&self, expr: &str, type_name: &str, list: bool) -> Option<String> {
|
|
204
|
+
if list {
|
|
205
|
+
let item = self.fits("item", type_name, false)?;
|
|
206
|
+
return Some(format!("matches!({expr}, crate::kernel::Json::Array(items) if items.iter().all(|item| {item}))"));
|
|
207
|
+
}
|
|
208
|
+
let (fields_meta, _) = self.fields_of(type_name)?;
|
|
209
|
+
let names: Vec<String> = fields_meta
|
|
210
|
+
.iter()
|
|
211
|
+
.map(|f| format!("{:?}", crate::attr::name(f)))
|
|
212
|
+
.collect();
|
|
213
|
+
Some(format!("matches!({expr}, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), {})))", names.join(" | ")))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[cfg(test)]
|
|
218
|
+
mod tests {
|
|
219
|
+
use super::*;
|
|
220
|
+
|
|
221
|
+
fn attribute(name: &str, type_name: &str, list: bool) -> String {
|
|
222
|
+
format!(r#"{{"name": "{name}", "type": "{type_name}", "list": {list}}}"#)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn value_object(name: &str, attributes: &[String]) -> String {
|
|
226
|
+
format!(
|
|
227
|
+
r#"{{"name": "{name}", "attributes": [{}]}}"#,
|
|
228
|
+
attributes.join(", ")
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pizzas' `Order`, cut down: a single-field name, a multi-field pizza holding two
|
|
233
|
+
// single-field value objects, a list of single-field tags, and two commands that both
|
|
234
|
+
// take an `amount` of a different single-field type.
|
|
235
|
+
fn order() -> Json {
|
|
236
|
+
let text = format!(
|
|
237
|
+
r#"{{"name": "Order",
|
|
238
|
+
"attributes": [{}, {}, {}],
|
|
239
|
+
"value_objects": [{}, {}, {}, {}, {}, {}],
|
|
240
|
+
"commands": [
|
|
241
|
+
{{"name": "CreatePizza", "attributes": [{}, {}]}},
|
|
242
|
+
{{"name": "AddTopping", "attributes": [{}]}},
|
|
243
|
+
{{"name": "Purchase", "attributes": [{}]}}
|
|
244
|
+
]}}"#,
|
|
245
|
+
attribute("name", "PizzaName", false),
|
|
246
|
+
attribute("pizza", "Pizza", false),
|
|
247
|
+
attribute("tags", "Tag", true),
|
|
248
|
+
value_object("PizzaName", &[attribute("value", "String", false)]),
|
|
249
|
+
value_object("Price", &[attribute("cents", "Integer", false)]),
|
|
250
|
+
value_object("Size", &[attribute("value", "String", false)]),
|
|
251
|
+
value_object("Tag", &[attribute("value", "String", false)]),
|
|
252
|
+
value_object("ToppingAmount", &[attribute("value", "Integer", false)]),
|
|
253
|
+
value_object(
|
|
254
|
+
"Pizza",
|
|
255
|
+
&[
|
|
256
|
+
attribute("price_cents", "Price", false),
|
|
257
|
+
attribute("size", "Size", false)
|
|
258
|
+
]
|
|
259
|
+
),
|
|
260
|
+
attribute("name", "PizzaName", false),
|
|
261
|
+
attribute("pizza", "Pizza", false),
|
|
262
|
+
attribute("amount", "ToppingAmount", false),
|
|
263
|
+
attribute("amount", "Price", false),
|
|
264
|
+
);
|
|
265
|
+
Json::parse(&text).unwrap()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn presenter() -> String {
|
|
269
|
+
let order = order();
|
|
270
|
+
let vos: Vec<&Json> = order
|
|
271
|
+
.get("value_objects")
|
|
272
|
+
.map_or_default(Json::each)
|
|
273
|
+
.iter()
|
|
274
|
+
.collect();
|
|
275
|
+
let by_name: HashMap<String, &Json> = vos
|
|
276
|
+
.iter()
|
|
277
|
+
.map(|vo| (vo.get("name").map(Json::to_s).unwrap_or_default(), *vo))
|
|
278
|
+
.collect();
|
|
279
|
+
emit_aggregate_presenter(&order, &by_name)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn a_single_field_attribute_presents_as_its_sole_field() {
|
|
284
|
+
assert!(presenter().contains(r#""name" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[test]
|
|
288
|
+
fn a_multi_field_value_object_keeps_its_object_and_unwraps_each_field() {
|
|
289
|
+
let text = presenter();
|
|
290
|
+
assert!(text.contains(r#""pizza" => match v { crate::kernel::Json::Object(fields) => crate::kernel::Json::Object("#));
|
|
291
|
+
assert!(text.contains(r#""price_cents" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "cents")"#));
|
|
292
|
+
assert!(text.contains(r#""size" => match v { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#[test]
|
|
296
|
+
fn a_list_of_single_field_value_objects_maps_item_wise() {
|
|
297
|
+
assert!(presenter().contains(r#""tags" => match v { crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| match item { crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == "value")"#));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn a_name_declared_with_two_types_is_told_apart_by_its_keys() {
|
|
302
|
+
let text = presenter();
|
|
303
|
+
let arm = text.split(r#""amount" => "#).nth(1).unwrap();
|
|
304
|
+
assert!(arm.starts_with(r#"if matches!(&v, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), "value")))"#));
|
|
305
|
+
assert!(arm.contains(r#"else if matches!(&v, crate::kernel::Json::Object(fields) if fields.iter().all(|(k, _)| matches!(k.as_str(), "cents")))"#));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn a_command_argument_already_named_by_an_attribute_adds_no_second_arm() {
|
|
310
|
+
assert_eq!(presenter().matches(r#""name" => "#).count(), 1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn the_router_names_each_aggregate_qualified_and_passes_any_other_through() {
|
|
315
|
+
let entry = AggregateEntry {
|
|
316
|
+
name: "Order".to_string(),
|
|
317
|
+
module_name: "order".to_string(),
|
|
318
|
+
record: "Order".to_string(),
|
|
319
|
+
commands: vec![],
|
|
320
|
+
entity_commands: vec![],
|
|
321
|
+
nested_entity_commands: vec![],
|
|
322
|
+
ports: vec![],
|
|
323
|
+
chapter_mod: "pizzas".to_string(),
|
|
324
|
+
domain_name: "Pizzas".to_string(),
|
|
325
|
+
reference_specs: vec![],
|
|
326
|
+
identified_by: vec![],
|
|
327
|
+
entities: vec![],
|
|
328
|
+
};
|
|
329
|
+
let text = emit_present_router(&[entry]);
|
|
330
|
+
assert!(
|
|
331
|
+
text.contains(r#""Pizzas::Order" => crate::generated::pizzas::order::present(state),"#)
|
|
332
|
+
);
|
|
333
|
+
assert!(text.contains("_ => state,"));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
use crate::exemplar::Exemplar;
|
|
5
5
|
use crate::json::Json;
|
|
6
|
+
use crate::presenters::unwrap_json_expr;
|
|
6
7
|
use crate::queries;
|
|
7
8
|
use crate::shared::ruby_to_i;
|
|
8
9
|
use crate::skip_reason::{reskip, skip, SkipReason};
|
|
@@ -1030,63 +1031,6 @@ fn group_by_leaf_check(
|
|
|
1030
1031
|
)
|
|
1031
1032
|
}
|
|
1032
1033
|
|
|
1033
|
-
// Port of `Value.materialize_unwrapped` (Ruby).
|
|
1034
|
-
fn unwrap_json_expr(
|
|
1035
|
-
expr: &str,
|
|
1036
|
-
type_name: &str,
|
|
1037
|
-
list: bool,
|
|
1038
|
-
aggregate: &Json,
|
|
1039
|
-
value_objects_by_name: &HashMap<String, &Json>,
|
|
1040
|
-
) -> String {
|
|
1041
|
-
if list {
|
|
1042
|
-
let inner = unwrap_json_expr("item", type_name, false, aggregate, value_objects_by_name);
|
|
1043
|
-
return format!("match {expr} {{ crate::kernel::Json::Array(items) => crate::kernel::Json::Array(items.into_iter().map(|item| {inner}).collect()), other => other }}");
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
let vo = value_objects_by_name.get(type_name).copied();
|
|
1047
|
-
let entity = aggregate
|
|
1048
|
-
.get("entities")
|
|
1049
|
-
.map_or_default(Json::each)
|
|
1050
|
-
.iter()
|
|
1051
|
-
.find(|e| e.get("name").map(Json::to_s).unwrap_or_default() == type_name);
|
|
1052
|
-
let fields_meta: Option<&[Json]> = vo.map_or_else(
|
|
1053
|
-
|| entity.and_then(|e| e.get("attributes").map(Json::each)),
|
|
1054
|
-
|vo| vo.get("attributes").map(Json::each),
|
|
1055
|
-
);
|
|
1056
|
-
let Some(fields_meta) = fields_meta else {
|
|
1057
|
-
return expr.to_string();
|
|
1058
|
-
};
|
|
1059
|
-
|
|
1060
|
-
if vo.is_some() && fields_meta.len() == 1 {
|
|
1061
|
-
let field = &fields_meta[0];
|
|
1062
|
-
let unwrapped = unwrap_json_expr(
|
|
1063
|
-
"field_value",
|
|
1064
|
-
crate::attr::type_name(field),
|
|
1065
|
-
crate::attr::list(field),
|
|
1066
|
-
aggregate,
|
|
1067
|
-
value_objects_by_name,
|
|
1068
|
-
);
|
|
1069
|
-
let field_name = crate::attr::name(field);
|
|
1070
|
-
return format!("match {expr} {{ crate::kernel::Json::Object(fields) => fields.into_iter().find(|(k, _)| k == {field_name:?}).map_or(crate::kernel::Json::Null, |(_, field_value)| {unwrapped}), other => other }}");
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
let inner_arms: Vec<String> = fields_meta
|
|
1074
|
-
.iter()
|
|
1075
|
-
.map(|f| {
|
|
1076
|
-
let unwrapped = unwrap_json_expr(
|
|
1077
|
-
"v",
|
|
1078
|
-
crate::attr::type_name(f),
|
|
1079
|
-
crate::attr::list(f),
|
|
1080
|
-
aggregate,
|
|
1081
|
-
value_objects_by_name,
|
|
1082
|
-
);
|
|
1083
|
-
format!("{:?} => {unwrapped},", crate::attr::name(f))
|
|
1084
|
-
})
|
|
1085
|
-
.collect();
|
|
1086
|
-
let inner_arms = inner_arms.join(" ");
|
|
1087
|
-
format!("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 }}")
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
1034
|
const READ_MODEL_TABLE_ROW_PLACEHOLDER: &str = "crate::kernel::read_model::ReadModelDef {\n verb: \"tmpl_verb\",\n reference_name: Some(\"tmpl_reference_name\"),\n heads: &[\n crate::kernel::read_model::ReadModelHead {\n aggregate: \"tmpl_aggregate\",\n as_name: \"tmpl_as_name\",\n many: true,\n is_root: false,\n reference_fields: &[\n crate::kernel::read_model::ReferenceField { target_aggregate: \"tmpl_target_aggregate\", field: \"tmpl_field\" },\n ],\n },\n ],\n filtered_heads: &[\n 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)) },\n ],\n authorization: Some(crate::kernel::named_query::TenantAuth { query_name: \"tmpl_query_name\", tenant_field: \"tmpl_tenant_field\", policy: \"tmpl_policy\" }),\n group_by: None,\n reduction: None,\n},";
|
|
1091
1035
|
|
|
1092
1036
|
pub fn emit_read_model_table(exemplar: &Exemplar, read_model_defs: &[ReadModelDef]) -> String {
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5.0.0
|
data/rust/host/src/dispatch.rs
CHANGED
|
@@ -715,6 +715,29 @@ pub async fn query(
|
|
|
715
715
|
parse_kernel_output(&output)
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
+
/// Answers `answer` (a kernel answer already computed and persisted) in the bare shape a caller
|
|
719
|
+
/// reads, by one more wasm run of the kernel's `{"present": answer}` input: each single-field
|
|
720
|
+
/// value object as its sole field. Nothing is stored or replayed.
|
|
721
|
+
pub async fn present(
|
|
722
|
+
wasm_path: &Path,
|
|
723
|
+
answer: serde_json::Value,
|
|
724
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
725
|
+
let owned_wasm_path = wasm_path.to_path_buf();
|
|
726
|
+
tokio::task::spawn_blocking(move || {
|
|
727
|
+
present_with(&answer, |input| wasm_runner::run(&owned_wasm_path, input))
|
|
728
|
+
})
|
|
729
|
+
.await?
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/// Presents `answer` as `present` does, with `run` standing in for the kernel (text in, text out).
|
|
733
|
+
pub(crate) fn present_with(
|
|
734
|
+
answer: &serde_json::Value,
|
|
735
|
+
run: impl FnOnce(&str) -> anyhow::Result<String>,
|
|
736
|
+
) -> anyhow::Result<serde_json::Value> {
|
|
737
|
+
let input = serde_json::json!({ "present": answer }).to_string();
|
|
738
|
+
parse_kernel_output(&run(&input)?)
|
|
739
|
+
}
|
|
740
|
+
|
|
718
741
|
#[cfg(test)]
|
|
719
742
|
pub(crate) mod tests {
|
|
720
743
|
|
|
@@ -963,6 +986,30 @@ pub(crate) mod tests {
|
|
|
963
986
|
}
|
|
964
987
|
}
|
|
965
988
|
|
|
989
|
+
#[test]
|
|
990
|
+
fn present_hands_the_kernel_the_answer_under_present_and_answers_what_it_returns() {
|
|
991
|
+
let answer = serde_json::json!({"instances": {"Shop::Item#a": {"name": {"value": "x"}}}});
|
|
992
|
+
let mut sent = String::new();
|
|
993
|
+
let presented = present_with(&answer, |input| {
|
|
994
|
+
sent = input.to_string();
|
|
995
|
+
Ok(r#"{"instances":{"Shop::Item#a":{"name":"x"}}}"#.to_string())
|
|
996
|
+
})
|
|
997
|
+
.unwrap();
|
|
998
|
+
assert_eq!(
|
|
999
|
+
serde_json::from_str::<serde_json::Value>(&sent).unwrap(),
|
|
1000
|
+
serde_json::json!({ "present": answer })
|
|
1001
|
+
);
|
|
1002
|
+
assert_eq!(presented["instances"]["Shop::Item#a"]["name"], "x");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#[test]
|
|
1006
|
+
fn present_is_an_error_when_the_kernel_fails() {
|
|
1007
|
+
let failed = present_with(&serde_json::json!({}), |_| {
|
|
1008
|
+
Ok(r#"{"error":"expected a top-level {\"steps\": [...]} object"}"#.to_string())
|
|
1009
|
+
});
|
|
1010
|
+
assert!(failed.is_err());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
966
1013
|
pub(crate) fn wasm_path() -> std::path::PathBuf {
|
|
967
1014
|
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.wasm")
|
|
968
1015
|
}
|
data/rust/host/src/server.rs
CHANGED
|
@@ -25,16 +25,36 @@ use tokio_postgres::Client;
|
|
|
25
25
|
|
|
26
26
|
/// The per-request dispatch decision, shared between the Lambda custom-runtime path and the
|
|
27
27
|
/// axum fallback route below. `body` is already-parsed JSON in one of its recognized shapes.
|
|
28
|
+
///
|
|
29
|
+
/// `"bare": true` beside any of them (`verb` with `to`/`with` or `args`, `read`, `query`) answers
|
|
30
|
+
/// the same outcome with each single-field value object as its bare field
|
|
31
|
+
/// (`{"name": "Margherita"}`, not `{"name": {"value": "Margherita"}}`). What is stored is the
|
|
32
|
+
/// same either way; without `bare` the answer is the stored shape.
|
|
28
33
|
pub async fn dispatch_body(body: Value, host: Host<'_>) -> Result<Value, Error> {
|
|
34
|
+
if let Some(response) = web::render(&body, host).await {
|
|
35
|
+
return Ok(response);
|
|
36
|
+
}
|
|
37
|
+
let answer = kernel_answer(&body, host).await?;
|
|
38
|
+
if !wants_bare(&body) {
|
|
39
|
+
return Ok(answer);
|
|
40
|
+
}
|
|
41
|
+
Ok(dispatch::present(host.wasm_path, answer)
|
|
42
|
+
.await
|
|
43
|
+
.map_err(|e| format!("{e:#}"))?)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn wants_bare(body: &Value) -> bool {
|
|
47
|
+
body.get("bare").and_then(Value::as_bool) == Some(true)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The kernel's outcome for a read, query or command body, in the stored shape.
|
|
51
|
+
async fn kernel_answer(body: &Value, host: Host<'_>) -> Result<Value, Error> {
|
|
29
52
|
let Host {
|
|
30
53
|
client,
|
|
31
54
|
wasm_path,
|
|
32
55
|
config,
|
|
33
56
|
..
|
|
34
57
|
} = host;
|
|
35
|
-
if let Some(response) = web::render(&body, host).await {
|
|
36
|
-
return Ok(response);
|
|
37
|
-
}
|
|
38
58
|
|
|
39
59
|
if body.get("read").and_then(serde_json::Value::as_bool) == Some(true) {
|
|
40
60
|
// `{e:#}`: anyhow's alternate Display walks `source()` to the real message,
|
|
@@ -48,7 +68,7 @@ pub async fn dispatch_body(body: Value, host: Host<'_>) -> Result<Value, Error>
|
|
|
48
68
|
// A query step reads current state and writes nothing to the journal.
|
|
49
69
|
if body.get("query").is_some() {
|
|
50
70
|
return Ok(crate::query_step::answer(
|
|
51
|
-
|
|
71
|
+
body,
|
|
52
72
|
crate::ir::ir(),
|
|
53
73
|
&config.domain,
|
|
54
74
|
client,
|
|
@@ -1041,6 +1061,58 @@ mod tests {
|
|
|
1041
1061
|
);
|
|
1042
1062
|
}
|
|
1043
1063
|
|
|
1064
|
+
// The checkout fixture's `Event`: `slug`, `name` and `capacity` hold one `value` field and
|
|
1065
|
+
// `price` one `cents` field, so each answers as a bare scalar when asked to.
|
|
1066
|
+
#[tokio::test]
|
|
1067
|
+
async fn a_bare_body_answers_single_field_value_objects_bare_and_the_default_stays_wrapped() {
|
|
1068
|
+
let client = crate::dispatch::tests::scratch_db("rust_host_serve_bare").await;
|
|
1069
|
+
crate::dispatch::tests::provision_lineage(
|
|
1070
|
+
&*client.lock().await,
|
|
1071
|
+
"CheckoutFixture",
|
|
1072
|
+
1,
|
|
1073
|
+
&["Event"],
|
|
1074
|
+
)
|
|
1075
|
+
.await;
|
|
1076
|
+
let config = LineageConfig {
|
|
1077
|
+
domain: "CheckoutFixture".to_string(),
|
|
1078
|
+
era: Some(1),
|
|
1079
|
+
mirrored: None,
|
|
1080
|
+
};
|
|
1081
|
+
let wasm = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm");
|
|
1082
|
+
let host = Host::new(&client, &wasm, &config, &crate::lambda_client::NeverInvoker);
|
|
1083
|
+
let schedule = serde_json::json!({
|
|
1084
|
+
"verb": "CheckoutFixture::Event.Schedule",
|
|
1085
|
+
"role": "Organizer",
|
|
1086
|
+
"with": {"slug": "intro", "name": "Intro", "price": 1500, "capacity": 10},
|
|
1087
|
+
"bare": true,
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
let scheduled = dispatch_body(schedule, host).await.unwrap();
|
|
1091
|
+
let wrapped = dispatch_body(serde_json::json!({"read": true}), host)
|
|
1092
|
+
.await
|
|
1093
|
+
.unwrap();
|
|
1094
|
+
let bare = dispatch_body(serde_json::json!({"read": true, "bare": true}), host)
|
|
1095
|
+
.await
|
|
1096
|
+
.unwrap();
|
|
1097
|
+
|
|
1098
|
+
let event = "CheckoutFixture::Event#intro";
|
|
1099
|
+
assert_eq!(
|
|
1100
|
+
scheduled["instances"][event]["name"], "Intro",
|
|
1101
|
+
"{scheduled}"
|
|
1102
|
+
);
|
|
1103
|
+
assert_eq!(
|
|
1104
|
+
scheduled["events"][0]["payload"]["price"], 1500,
|
|
1105
|
+
"{scheduled}"
|
|
1106
|
+
);
|
|
1107
|
+
assert_eq!(
|
|
1108
|
+
wrapped["instances"][event]["name"],
|
|
1109
|
+
serde_json::json!({"value": "Intro"}),
|
|
1110
|
+
"{wrapped}"
|
|
1111
|
+
);
|
|
1112
|
+
assert_eq!(bare["instances"][event]["price"], 1500, "{bare}");
|
|
1113
|
+
assert_eq!(bare["instances"][event]["capacity"], 10, "{bare}");
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1044
1116
|
#[test]
|
|
1045
1117
|
fn build_is_the_env_value_when_set() {
|
|
1046
1118
|
assert_eq!(version_body("e", "h", Some("cms-1"))["build"], "cms-1");
|
data/rust/src/kernel/cli.rs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//! The stdin/stdout JSON CLI shared by the native binary and the wasm32-wasip1 module.
|
|
2
|
-
//! `run` reads `{"steps": [...]}` and hands each step to `kernel::orchestrate` (ADR 0012)
|
|
2
|
+
//! `run` reads `{"steps": [...]}` and hands each step to `kernel::orchestrate` (ADR 0012), or
|
|
3
|
+
//! `{"present": answer}` and answers that answer bare (`kernel::present`).
|
|
3
4
|
|
|
4
5
|
use super::{
|
|
5
6
|
named_query, orchestrate, query_comparators, read_model, repository, AggregateScan,
|
|
@@ -24,6 +25,18 @@ pub fn run(input: &str) -> String {
|
|
|
24
25
|
Err(e) => return error_output(&format!("invalid JSON on stdin: {e}")),
|
|
25
26
|
};
|
|
26
27
|
|
|
28
|
+
// `{"present": answer}`: a run's answer in the bare shape a caller reads (single-field value
|
|
29
|
+
// objects as their sole field); no step runs, and stored state stays wrapped.
|
|
30
|
+
// Parsed again keeping number text, so a Float the answer carries is written back unchanged.
|
|
31
|
+
if parsed.get("present").is_some() {
|
|
32
|
+
let answer = Json::parse_keeping_number_text(input)
|
|
33
|
+
.ok()
|
|
34
|
+
.and_then(|whole| whole.get("present").cloned())
|
|
35
|
+
.unwrap_or(Json::Null);
|
|
36
|
+
return super::present::present_answer(answer, crate::generated::active::present)
|
|
37
|
+
.to_json_string();
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// Optional `"needs"`: which commands need which outside facts (ADR 0081), passed by the host.
|
|
28
41
|
super::needs::install(parsed.get("needs"));
|
|
29
42
|
// Optional `"query_needs"`: which queries need which outside facts, filled the same way.
|
data/rust/src/kernel/json.rs
CHANGED
|
@@ -371,9 +371,21 @@ impl Json {
|
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
pub fn parse(input: &str) -> Result<Self, String> {
|
|
374
|
+
Self::parse_with(input, false)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// Parses `input` keeping every number literal's source text, so writing the value back
|
|
378
|
+
/// reproduces each number exactly as given (`0.0` stays `0.0`, not `0`). For a document that
|
|
379
|
+
/// is only reshaped and written out again, never evaluated.
|
|
380
|
+
pub fn parse_keeping_number_text(input: &str) -> Result<Self, String> {
|
|
381
|
+
Self::parse_with(input, true)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn parse_with(input: &str, keep_number_text: bool) -> Result<Self, String> {
|
|
374
385
|
let mut parser = Parser {
|
|
375
386
|
chars: input.chars().peekable(),
|
|
376
387
|
depth: 0,
|
|
388
|
+
keep_number_text,
|
|
377
389
|
};
|
|
378
390
|
let value = parser.parse_value()?;
|
|
379
391
|
parser.skip_ws();
|
|
@@ -703,6 +715,7 @@ fn write_escaped_string(s: &str, out: &mut String) {
|
|
|
703
715
|
struct Parser<'a> {
|
|
704
716
|
chars: std::iter::Peekable<std::str::Chars<'a>>,
|
|
705
717
|
depth: usize,
|
|
718
|
+
keep_number_text: bool,
|
|
706
719
|
}
|
|
707
720
|
|
|
708
721
|
/// How deeply arrays and objects may nest before `parse` refuses. Each level is a recursive call,
|
|
@@ -895,7 +908,8 @@ impl Parser<'_> {
|
|
|
895
908
|
// A literal with a `.`/exponent is a genuine `Float`, and Ruby's `Float` is the same
|
|
896
909
|
// `f64` Rust's is, so there is nothing to preserve there.
|
|
897
910
|
let is_plain_integer = !s.contains('.') && !s.contains(['e', 'E']);
|
|
898
|
-
let keeps_source =
|
|
911
|
+
let keeps_source =
|
|
912
|
+
self.keep_number_text || (is_plain_integer && canonical_integer_digits(value) != s);
|
|
899
913
|
// `1e999` parses to infinity with no source text kept, and `write` would print it as `inf`:
|
|
900
914
|
// not JSON, so refuse it. A plain integer keeps its digits, which `write` re-emits as-is.
|
|
901
915
|
if !value.is_finite() && !keeps_source {
|
data/rust/src/kernel/mod.rs
CHANGED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//! The kernel's `{"present": answer}` input: a run's answer turned into the bare shape a caller
|
|
2
|
+
//! is answered with, by the generated `present(aggregate, state)` router.
|
|
3
|
+
//!
|
|
4
|
+
//! It runs no step and touches no store. Only what a caller reads is presented: `instances`,
|
|
5
|
+
//! each event's `payload`, and each declared query's `rows`. Everything a host stores or replays
|
|
6
|
+
//! (`mutations`, `sagas`, `saga_snapshot`, ...) is left exactly as it came, because stored state
|
|
7
|
+
//! stays wrapped.
|
|
8
|
+
|
|
9
|
+
use super::Json;
|
|
10
|
+
|
|
11
|
+
/// The generated router's shape: one `"Domain::Aggregate"` state in, its bare shape out.
|
|
12
|
+
pub type Presenter = fn(&str, Json) -> Json;
|
|
13
|
+
|
|
14
|
+
/// Returns `answer` with its `instances`, event payloads and declared query rows presented
|
|
15
|
+
/// through `present`; every other key passes through verbatim.
|
|
16
|
+
pub fn present_answer(answer: Json, present: Presenter) -> Json {
|
|
17
|
+
let Json::Object(fields) = answer else {
|
|
18
|
+
return answer;
|
|
19
|
+
};
|
|
20
|
+
Json::Object(
|
|
21
|
+
fields
|
|
22
|
+
.into_iter()
|
|
23
|
+
.map(|(key, value)| {
|
|
24
|
+
let value = match key.as_str() {
|
|
25
|
+
"instances" => present_instances(value, present),
|
|
26
|
+
"events" => map_items(value, |event| present_event(event, present)),
|
|
27
|
+
"queries" => map_items(value, |entry| present_query(entry, present)),
|
|
28
|
+
_ => value,
|
|
29
|
+
};
|
|
30
|
+
(key, value)
|
|
31
|
+
})
|
|
32
|
+
.collect(),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// `"Domain::Aggregate#id" => state`: the aggregate is the key up to its first `#`.
|
|
37
|
+
fn present_instances(instances: Json, present: Presenter) -> Json {
|
|
38
|
+
let Json::Object(entries) = instances else {
|
|
39
|
+
return instances;
|
|
40
|
+
};
|
|
41
|
+
Json::Object(
|
|
42
|
+
entries
|
|
43
|
+
.into_iter()
|
|
44
|
+
.map(|(key, state)| {
|
|
45
|
+
let aggregate = key.split_once('#').map_or(key.as_str(), |(head, _)| head);
|
|
46
|
+
let state = present(aggregate, state);
|
|
47
|
+
(key, state)
|
|
48
|
+
})
|
|
49
|
+
.collect(),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn present_event(event: Json, present: Presenter) -> Json {
|
|
54
|
+
let Some(aggregate) = event
|
|
55
|
+
.get("aggregate")
|
|
56
|
+
.and_then(Json::as_str)
|
|
57
|
+
.map(str::to_string)
|
|
58
|
+
else {
|
|
59
|
+
return event;
|
|
60
|
+
};
|
|
61
|
+
replace_field(event, "payload", |payload| present(&aggregate, payload))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A declared query is named `"Domain::Aggregate.Query"`; its rows are that aggregate's state.
|
|
65
|
+
// A read model or an ad hoc filter names no aggregate this way, and the router passes an unknown
|
|
66
|
+
// name through, so its rows come back as they were.
|
|
67
|
+
fn present_query(entry: Json, present: Presenter) -> Json {
|
|
68
|
+
let Some(aggregate) = entry
|
|
69
|
+
.get("query")
|
|
70
|
+
.and_then(Json::as_str)
|
|
71
|
+
.and_then(|question| question.rsplit_once('.'))
|
|
72
|
+
.map(|(head, _)| head.to_string())
|
|
73
|
+
else {
|
|
74
|
+
return entry;
|
|
75
|
+
};
|
|
76
|
+
replace_field(entry, "rows", |rows| {
|
|
77
|
+
map_items(rows, |row| present(&aggregate, row))
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn replace_field(object: Json, name: &str, f: impl Fn(Json) -> Json) -> Json {
|
|
82
|
+
let Json::Object(fields) = object else {
|
|
83
|
+
return object;
|
|
84
|
+
};
|
|
85
|
+
Json::Object(
|
|
86
|
+
fields
|
|
87
|
+
.into_iter()
|
|
88
|
+
.map(|(key, value)| {
|
|
89
|
+
let value = if key == name { f(value) } else { value };
|
|
90
|
+
(key, value)
|
|
91
|
+
})
|
|
92
|
+
.collect(),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn map_items(list: Json, f: impl Fn(Json) -> Json) -> Json {
|
|
97
|
+
match list {
|
|
98
|
+
Json::Array(items) => Json::Array(items.into_iter().map(f).collect()),
|
|
99
|
+
other => other,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[cfg(test)]
|
|
104
|
+
mod tests {
|
|
105
|
+
use super::*;
|
|
106
|
+
|
|
107
|
+
// Stands in for the generated router: `Shop::Item` unwraps its `name` object to its
|
|
108
|
+
// `value`; any other aggregate passes through, as the generated one does.
|
|
109
|
+
fn shop_present(aggregate: &str, state: Json) -> Json {
|
|
110
|
+
if aggregate != "Shop::Item" {
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
replace_field(state, "name", |name| {
|
|
114
|
+
name.get("value").cloned().unwrap_or(name)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn present(text: &str) -> String {
|
|
119
|
+
present_answer(Json::parse(text).unwrap(), shop_present).to_json_string()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn instances_are_presented_by_the_aggregate_their_key_names() {
|
|
124
|
+
assert_eq!(
|
|
125
|
+
present(
|
|
126
|
+
r#"{"instances":{"Shop::Item#a":{"name":{"value":"x"}},"Other::Thing#b":{"name":{"value":"y"}}}}"#
|
|
127
|
+
),
|
|
128
|
+
r#"{"instances":{"Shop::Item#a":{"name":"x"},"Other::Thing#b":{"name":{"value":"y"}}}}"#
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[test]
|
|
133
|
+
fn an_event_payload_is_presented_by_its_aggregate() {
|
|
134
|
+
assert_eq!(
|
|
135
|
+
present(
|
|
136
|
+
r#"{"events":[{"name":"Named","aggregate":"Shop::Item","payload":{"name":{"value":"x"}}}]}"#
|
|
137
|
+
),
|
|
138
|
+
r#"{"events":[{"name":"Named","aggregate":"Shop::Item","payload":{"name":"x"}}]}"#
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn declared_query_rows_are_presented_by_the_aggregate_the_query_names() {
|
|
144
|
+
assert_eq!(
|
|
145
|
+
present(
|
|
146
|
+
r#"{"queries":[{"query":"Shop::Item.All","rows":[{"id":"a","name":{"value":"x"}}],"reference_rows":[{"name":{"value":"x"}}]}]}"#
|
|
147
|
+
),
|
|
148
|
+
r#"{"queries":[{"query":"Shop::Item.All","rows":[{"id":"a","name":"x"}],"reference_rows":[{"name":{"value":"x"}}]}]}"#
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn mutations_sagas_and_refusals_pass_through_verbatim() {
|
|
154
|
+
let text = r#"{"mutations":[[{"aggregate":"Shop::Item","state":{"name":{"value":"x"}}}]],"sagas":[],"refusals":[{"verb":"Shop::Item.Name","error":"no"}]}"#;
|
|
155
|
+
assert_eq!(present(text), text);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Through `cli::run` and the generated pizzas router: what `{"present": answer}` answers for a
|
|
160
|
+
// real run's answer.
|
|
161
|
+
#[cfg(test)]
|
|
162
|
+
#[cfg(feature = "pizzas")]
|
|
163
|
+
mod pizzas_tests {
|
|
164
|
+
use crate::kernel::cli::run;
|
|
165
|
+
use crate::kernel::Json;
|
|
166
|
+
|
|
167
|
+
const STEPS: &str = r#"{"steps":[
|
|
168
|
+
{"verb":"Pizzas::Order.CreatePizza","with":{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"}}},
|
|
169
|
+
{"verb":"Pizzas::Order.AddTopping","to":"Margherita","with":{"topping":"basil","amount":2}},
|
|
170
|
+
{"verb":"Pizzas::Order.Purchase","to":"Margherita","with":{"amount":1500,"customer_name":"Ann"}},
|
|
171
|
+
{"query":"Pizzas::Order.Everything"}]}"#;
|
|
172
|
+
|
|
173
|
+
fn answers() -> (Json, Json) {
|
|
174
|
+
let answer = run(STEPS);
|
|
175
|
+
let presented = run(&format!(r#"{{"present":{answer}}}"#));
|
|
176
|
+
(
|
|
177
|
+
Json::parse(&answer).unwrap(),
|
|
178
|
+
Json::parse(&presented).unwrap(),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn text(value: Option<&Json>) -> String {
|
|
183
|
+
value.map_or_else(|| "missing".to_string(), Json::to_json_string)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn items<'a>(value: &'a Json, key: &str) -> &'a [Json] {
|
|
187
|
+
value.get(key).and_then(Json::as_array).unwrap_or_default()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn an_instance_answers_bare_and_a_multi_field_value_object_keeps_its_object() {
|
|
192
|
+
let (_, presented) = answers();
|
|
193
|
+
assert_eq!(
|
|
194
|
+
text(presented.dig("instances.Pizzas::Order#Margherita")),
|
|
195
|
+
r#"{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"},"toppings":[{"name":"basil","amount":2}],"customer_name":"Ann","status":"sold"}"#
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn each_event_payload_answers_bare_even_where_two_commands_type_one_name_apart() {
|
|
201
|
+
let (_, presented) = answers();
|
|
202
|
+
let payloads: Vec<String> = items(&presented, "events")
|
|
203
|
+
.iter()
|
|
204
|
+
.map(|event| text(event.get("payload")))
|
|
205
|
+
.collect();
|
|
206
|
+
assert_eq!(
|
|
207
|
+
payloads,
|
|
208
|
+
[
|
|
209
|
+
r#"{"name":"Margherita","pizza":{"price_cents":1200,"size":"large"}}"#,
|
|
210
|
+
r#"{"topping":"basil","amount":2}"#,
|
|
211
|
+
r#"{"amount":1500,"customer_name":"Ann"}"#,
|
|
212
|
+
]
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[test]
|
|
217
|
+
fn a_declared_query_row_answers_bare() {
|
|
218
|
+
let (_, presented) = answers();
|
|
219
|
+
assert_eq!(
|
|
220
|
+
text(items(&presented, "queries")[0].get("rows")),
|
|
221
|
+
r#"[{"id":"Margherita","name":"Margherita","pizza":{"price_cents":1200,"size":"large"},"toppings":[{"name":"basil","amount":2}],"customer_name":"Ann","status":"sold"}]"#
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn mutations_and_the_run_without_present_stay_wrapped() {
|
|
227
|
+
let (answer, presented) = answers();
|
|
228
|
+
assert_eq!(
|
|
229
|
+
text(presented.get("mutations")),
|
|
230
|
+
text(answer.get("mutations"))
|
|
231
|
+
);
|
|
232
|
+
assert_eq!(
|
|
233
|
+
text(answer.dig("instances.Pizzas::Order#Margherita.name")),
|
|
234
|
+
r#"{"value":"Margherita"}"#
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[cfg(test)]
|
|
240
|
+
#[cfg(feature = "tally")]
|
|
241
|
+
mod tally_tests {
|
|
242
|
+
use crate::kernel::cli::run;
|
|
243
|
+
use crate::kernel::Json;
|
|
244
|
+
|
|
245
|
+
#[test]
|
|
246
|
+
fn a_list_of_single_field_value_objects_answers_as_scalars_and_a_float_stays_one() {
|
|
247
|
+
let answer = run(
|
|
248
|
+
r#"{"steps":[{"verb":"Tallying::Tally.Open","with":{"tag":"a"}},
|
|
249
|
+
{"verb":"Tallying::Tally.TagWith","to":"a","with":{"label":"red"}},
|
|
250
|
+
{"verb":"Tallying::Tally.TagWith","to":"a","with":{"label":"blue"}}]}"#,
|
|
251
|
+
);
|
|
252
|
+
let presented =
|
|
253
|
+
Json::parse_keeping_number_text(&run(&format!(r#"{{"present":{answer}}}"#))).unwrap();
|
|
254
|
+
let tally = presented.dig("instances.Tallying::Tally#a").unwrap();
|
|
255
|
+
assert_eq!(
|
|
256
|
+
tally.get("tags").unwrap().to_json_string(),
|
|
257
|
+
r#"["red","blue"]"#
|
|
258
|
+
);
|
|
259
|
+
assert_eq!(tally.get("weight").unwrap().to_json_string(), "0.0");
|
|
260
|
+
}
|
|
261
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
@@ -1543,6 +1543,7 @@ files:
|
|
|
1543
1543
|
- lib/hecks/runtime/value/invariant_violation.rb
|
|
1544
1544
|
- lib/hecks/runtime/value/references.rb
|
|
1545
1545
|
- lib/hecks/runtime/value/shape_checks.rb
|
|
1546
|
+
- lib/hecks/runtime/value/unwrapping.rb
|
|
1546
1547
|
- lib/hecks/runtime/value/validation.rb
|
|
1547
1548
|
- lib/hecks/rust_build.rb
|
|
1548
1549
|
- lib/hecks/rust_build/conformance.rb
|
|
@@ -1685,9 +1686,9 @@ files:
|
|
|
1685
1686
|
- lib/rubocop/cop/hecks/symbol_string_key_mix.rb
|
|
1686
1687
|
- lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
|
|
1687
1688
|
- lib/rubocop/cop/hecks/untyped_raise.rb
|
|
1688
|
-
- prebuilt/
|
|
1689
|
-
- prebuilt/
|
|
1690
|
-
- prebuilt/verdicts-
|
|
1689
|
+
- prebuilt/ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal
|
|
1690
|
+
- prebuilt/ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal
|
|
1691
|
+
- prebuilt/verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json
|
|
1691
1692
|
- qa/settings.yml
|
|
1692
1693
|
- rust/.cargo/config.toml
|
|
1693
1694
|
- rust/.cargo/mutants.toml
|
|
@@ -1743,6 +1744,7 @@ files:
|
|
|
1743
1744
|
- rust/codegen/src/optional_pass.rs
|
|
1744
1745
|
- rust/codegen/src/ports.rs
|
|
1745
1746
|
- rust/codegen/src/prelude.rs
|
|
1747
|
+
- rust/codegen/src/presenters.rs
|
|
1746
1748
|
- rust/codegen/src/queries.rs
|
|
1747
1749
|
- rust/codegen/src/reactions.rs
|
|
1748
1750
|
- rust/codegen/src/read_models.rs
|
|
@@ -1946,6 +1948,7 @@ files:
|
|
|
1946
1948
|
- rust/src/kernel/orchestrate.rs
|
|
1947
1949
|
- rust/src/kernel/orchestrate_guard_tests.rs
|
|
1948
1950
|
- rust/src/kernel/pattern.rs
|
|
1951
|
+
- rust/src/kernel/present.rs
|
|
1949
1952
|
- rust/src/kernel/query_comparators.rs
|
|
1950
1953
|
- rust/src/kernel/query_ordering.rs
|
|
1951
1954
|
- rust/src/kernel/read_model.rs
|
|
File without changes
|
|
File without changes
|