yes-core 1.0.0 → 1.1.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/CHANGELOG.md +4 -0
- data/lib/yes/core/aggregate/draftable.rb +3 -1
- data/lib/yes/core/middlewares.rb +17 -0
- data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +112 -0
- data/lib/yes/core/test_support/aggregate/matchers.rb +81 -0
- data/lib/yes/core/test_support/aggregate/shared_examples.rb +77 -0
- data/lib/yes/core/test_support/event_helpers.rb +73 -1
- data/lib/yes/core/test_support.rb +3 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +26 -14
- data/lib/yes/core/utils/command_utils.rb +26 -9
- data/lib/yes/core/version.rb +1 -1
- data/lib/yes/core.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3e28057d0c8b0fd6c0bc4f4692c55609b353258637320027efe1d2bcbb02841
|
|
4
|
+
data.tar.gz: fdb462463528fdbecf3df473f5c0f98601a8d301ec7e4d52f98ec0b2aabdc0f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 415e51919c284f2207fbd6ee6dfd9a9427830a6f20ab88986a69b94c14c62b15a75d0fe19360d58088a3d4de8beec953fa78e69c180458874c1d639d677fd9e0
|
|
7
|
+
data.tar.gz: 85a8557a897a3c2d02b315bf1e637f2bd6837300bf65b71430c314d9a047b9663cf8d33cfaeb7a681ea2bb5eaaf645a4297e351c3304abd56c8026a03d978792
|
data/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,7 @@ module Yes
|
|
|
15
15
|
included do
|
|
16
16
|
class << self
|
|
17
17
|
attr_accessor :_draft_context, :_draft_aggregate, :_changes_read_model_name,
|
|
18
|
+
:_changes_read_model_explicit,
|
|
18
19
|
:_draft_foreign_key, :_is_draftable, :_changes_read_model_public
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -52,7 +53,8 @@ module Yes
|
|
|
52
53
|
self._draft_context = draft_config[:context] || context
|
|
53
54
|
self._draft_aggregate = draft_config[:aggregate] || "#{aggregate}Draft"
|
|
54
55
|
|
|
55
|
-
self.
|
|
56
|
+
self._changes_read_model_explicit = changes_read_model.present?
|
|
57
|
+
self._changes_read_model_name = if changes_read_model.present?
|
|
56
58
|
changes_read_model.to_s
|
|
57
59
|
else
|
|
58
60
|
"#{read_model_name}_change"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
class << self
|
|
7
|
+
# Returns middleware keys excluding the specified one.
|
|
8
|
+
#
|
|
9
|
+
# @param middleware_name [Symbol] the middleware key to exclude
|
|
10
|
+
# @return [Array<Symbol>] remaining middleware keys
|
|
11
|
+
def without(middleware_name)
|
|
12
|
+
PgEventstore.config.middlewares.except(middleware_name).keys
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module TestSupport
|
|
6
|
+
module Aggregate
|
|
7
|
+
# DSL for writing concise aggregate command specs.
|
|
8
|
+
#
|
|
9
|
+
# Provides `command`, `success`, `invalid`, `no_change`, and `setup` methods
|
|
10
|
+
# that generate RSpec describe/context blocks with appropriate shared examples.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# RSpec.describe MyContext::MyAggregate::Aggregate, type: :aggregate do
|
|
14
|
+
# command 'do_something' do
|
|
15
|
+
# let(:command_data) { { name: 'test' } }
|
|
16
|
+
# let(:success_attributes) { { name: 'test' } }
|
|
17
|
+
#
|
|
18
|
+
# success
|
|
19
|
+
# invalid 'when precondition not met'
|
|
20
|
+
# no_change
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module CommandTestDsl
|
|
24
|
+
# Defines a test block for a command
|
|
25
|
+
#
|
|
26
|
+
# @param command_name [String, Symbol] the name of the command to test
|
|
27
|
+
# @param options [Array<Hash>] additional options (e.g., `draft: true`, VCR cassettes)
|
|
28
|
+
# @yield block for configuring test cases with success/invalid/no_change
|
|
29
|
+
def command(command_name, *options, &block)
|
|
30
|
+
describe command_name.to_s, *options do
|
|
31
|
+
let(:draft) { options.first&.dig(:draft) }
|
|
32
|
+
|
|
33
|
+
let(:aggregate) { described_class.new(draft:) } unless method_defined?(:aggregate)
|
|
34
|
+
|
|
35
|
+
subject { aggregate.public_send(command, command_data, guards: !draft) }
|
|
36
|
+
|
|
37
|
+
let(:command) { command_name.to_sym }
|
|
38
|
+
let(:aggregate_class) { aggregate.class }
|
|
39
|
+
let(:command_data_with_id) do
|
|
40
|
+
{ "#{aggregate_class.aggregate.underscore}_id" => aggregate.id }.merge(command_data)
|
|
41
|
+
end
|
|
42
|
+
let(:command_data) { {} }
|
|
43
|
+
let(:expected_event_type) do
|
|
44
|
+
"#{aggregate_class.context}::#{aggregate_class.aggregate}" \
|
|
45
|
+
"#{'Draft' if draft}#{aggregate_class.commands[command].event_name.to_s.classify}"
|
|
46
|
+
end
|
|
47
|
+
let(:expected_event_data) { command_data_with_id }
|
|
48
|
+
let(:expected_event_metadata) { nil }
|
|
49
|
+
let(:success_attributes) { command_data.without(:locale) } unless method_defined?(:success_attributes)
|
|
50
|
+
|
|
51
|
+
class_eval(&block) if block_given?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Defines a test case for a successful command execution
|
|
56
|
+
#
|
|
57
|
+
# @param description [String] optional description
|
|
58
|
+
# @param options [Hash] additional options (e.g., VCR cassettes)
|
|
59
|
+
# @yield optional block for additional setup or custom assertions
|
|
60
|
+
def success(description = 'when successfully executing command', options = {}, &block)
|
|
61
|
+
context description, options do
|
|
62
|
+
instance_eval(&block) if block_given?
|
|
63
|
+
|
|
64
|
+
it_behaves_like 'successful command'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Defines a test case for a command that causes no state change
|
|
69
|
+
#
|
|
70
|
+
# @param description [String] optional description
|
|
71
|
+
# @param options [Hash] additional options
|
|
72
|
+
# @yield optional block for additional setup
|
|
73
|
+
def no_change(description = 'when command causes no change', options = {}, &block)
|
|
74
|
+
context description.to_s, options do
|
|
75
|
+
instance_eval(&block) if block_given?
|
|
76
|
+
|
|
77
|
+
before { aggregate.public_send(command, command_data) }
|
|
78
|
+
|
|
79
|
+
it_behaves_like 'no change transition'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Defines a test case for an invalid transition
|
|
84
|
+
#
|
|
85
|
+
# @param description [String] description of the invalid scenario
|
|
86
|
+
# @param options [Hash] additional options
|
|
87
|
+
# @yield optional block for additional setup
|
|
88
|
+
def invalid(description, options = {}, &block)
|
|
89
|
+
context "when #{description}", options do
|
|
90
|
+
instance_eval(&block) if block_given?
|
|
91
|
+
|
|
92
|
+
it_behaves_like 'invalid transition'
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Alias for `before` — used for readable aggregate setup within command blocks
|
|
97
|
+
#
|
|
98
|
+
# @yield block for setup actions
|
|
99
|
+
def setup(&)
|
|
100
|
+
before(&)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if defined?(RSpec)
|
|
109
|
+
RSpec.configure do |config|
|
|
110
|
+
config.extend Yes::Core::TestSupport::Aggregate::CommandTestDsl, type: :aggregate
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec::Matchers.define :have_authorizer do
|
|
4
|
+
match do |actual|
|
|
5
|
+
actual&.authorizer_class&.< Yes::Core::Authorization::CommandAuthorizer
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
description do
|
|
9
|
+
'have an authorizer'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec::Matchers.define :have_read_model_class do |read_model_class|
|
|
14
|
+
match do |aggregate|
|
|
15
|
+
aggregate.read_model_class == read_model_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failure_message do |aggregate|
|
|
19
|
+
"expected #{aggregate} to have read model class #{read_model_class}" \
|
|
20
|
+
"\n actual read model class is #{aggregate.read_model_class}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
RSpec::Matchers.define :have_cerbos_authorizer do
|
|
25
|
+
match do |aggregate|
|
|
26
|
+
next false unless aggregate.authorizer_class&.< Yes::Core::Authorization::CommandCerbosAuthorizer
|
|
27
|
+
|
|
28
|
+
next false if @read_model_class && aggregate.authorizer_options&.read_model_class != @read_model_class
|
|
29
|
+
|
|
30
|
+
next false if @resource_name && aggregate.authorizer_options&.resource_name != @resource_name
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
chain :with_read_model_class do |read_model_class|
|
|
36
|
+
@read_model_class = read_model_class
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
chain :with_resource_name do |resource_name|
|
|
40
|
+
@resource_name = resource_name
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
description do
|
|
44
|
+
msg = 'have a Cerbos authorizer'
|
|
45
|
+
msg += " with read model class #{@read_model_class}" if @read_model_class
|
|
46
|
+
msg += " with resource name #{@resource_name}" if @resource_name
|
|
47
|
+
msg
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
failure_message do |aggregate|
|
|
51
|
+
msg = "expected #{aggregate} to have a Cerbos authorizer"
|
|
52
|
+
msg += "\n with read model class #{@read_model_class}" if @read_model_class
|
|
53
|
+
msg += "\n with resource name #{@resource_name}" if @resource_name
|
|
54
|
+
msg += "\n actual read model class is #{aggregate.authorizer_options&.read_model_class}" if @read_model_class
|
|
55
|
+
msg += "\n actual resource name is #{aggregate.authorizer_options&.resource_name}" if @resource_name
|
|
56
|
+
msg
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RSpec::Matchers.define :have_parent do |parent_name|
|
|
61
|
+
match do |aggregate|
|
|
62
|
+
@parent = aggregate.parent_aggregates.with_indifferent_access[parent_name]
|
|
63
|
+
|
|
64
|
+
return false unless @parent
|
|
65
|
+
|
|
66
|
+
return true unless @context
|
|
67
|
+
|
|
68
|
+
@parent[:context] == @context
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
chain :with_context do |context|
|
|
72
|
+
@context = context
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
failure_message do |aggregate|
|
|
76
|
+
msg = "expected #{aggregate} to have parent #{parent_name}"
|
|
77
|
+
msg += "\n with context #{@context}" if @context
|
|
78
|
+
msg += "\n actual context is #{@parent[:context].presence || aggregate.context}" if @parent && @context
|
|
79
|
+
msg
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_context 'with given events' do
|
|
4
|
+
before do
|
|
5
|
+
given_events.each do |event_data|
|
|
6
|
+
event = event_instance(event_data)
|
|
7
|
+
stream = event_stream(event_data)
|
|
8
|
+
append_event(stream, event)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec.shared_examples 'successful command' do
|
|
14
|
+
let(:event) { aggregate.events.map(&:last).last }
|
|
15
|
+
let(:expected_event_metadata) { nil }
|
|
16
|
+
|
|
17
|
+
it 'correctly changes the aggregate state' do
|
|
18
|
+
if success_attributes.any?
|
|
19
|
+
expect { subject }.to change {
|
|
20
|
+
aggregate.read_model.attributes.to_h.symbolize_keys.slice(*success_attributes.keys)
|
|
21
|
+
}.to(success_attributes)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'publishes expected event' do
|
|
26
|
+
subject
|
|
27
|
+
aggregate_failures do
|
|
28
|
+
expect(event.type).to eq(expected_event_type)
|
|
29
|
+
expect(event.data).to eq(expected_event_data.deep_stringify_keys)
|
|
30
|
+
expect(event.metadata).to include(expected_event_metadata.deep_stringify_keys) if expected_event_metadata
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec.shared_examples 'invalid transition' do
|
|
36
|
+
it 'raises InvalidTransition error' do
|
|
37
|
+
expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'does not change the aggregate state' do
|
|
41
|
+
success_attributes.each_key do |attribute|
|
|
42
|
+
expect { subject }.not_to(change { aggregate.public_send(attribute) })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'does not publish event' do
|
|
47
|
+
expect { subject }.not_to(
|
|
48
|
+
change do
|
|
49
|
+
aggregate.events.map(&:flatten).flatten.count
|
|
50
|
+
rescue PgEventstore::StreamNotFoundError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
RSpec.shared_examples 'no change transition' do
|
|
58
|
+
it 'raises NoChangeTransition error' do
|
|
59
|
+
expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'does not change the aggregate state' do
|
|
63
|
+
success_attributes.each_key do |attribute|
|
|
64
|
+
expect { subject }.not_to(change { aggregate.public_send(attribute) })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'does not publish event' do
|
|
69
|
+
expect { subject }.not_to(
|
|
70
|
+
change do
|
|
71
|
+
aggregate.events.map(&:flatten).flatten.count
|
|
72
|
+
rescue PgEventstore::StreamNotFoundError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -4,16 +4,33 @@ module Yes
|
|
|
4
4
|
module Core
|
|
5
5
|
module TestSupport
|
|
6
6
|
# Helpers for working with PgEventstore events in tests.
|
|
7
|
+
#
|
|
8
|
+
# @example Include in RSpec
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include Yes::Core::TestSupport::EventHelpers
|
|
11
|
+
# end
|
|
7
12
|
module EventHelpers
|
|
13
|
+
# Appends an event to a stream
|
|
14
|
+
#
|
|
15
|
+
# @param stream [PgEventstore::Stream]
|
|
16
|
+
# @param event [Yes::Core::Event]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def append_event(stream, event)
|
|
19
|
+
PgEventstore.client.append_to_stream(stream, event)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Appends an event to a stream and reloads it
|
|
23
|
+
#
|
|
8
24
|
# @param stream [PgEventstore::Stream]
|
|
9
25
|
# @param event [Yes::Core::Event]
|
|
10
26
|
# @return [Yes::Core::Event]
|
|
11
27
|
def append_and_reload_event(stream, event)
|
|
12
|
-
|
|
28
|
+
append_event(stream, event)
|
|
13
29
|
PgEventstore.client.read(stream, options: { max_count: 1, direction: :desc }).first
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
# Reads eventstore and returns events from the stream or an empty array if stream does not exist
|
|
33
|
+
#
|
|
17
34
|
# @param stream [PgEventstore::Stream]
|
|
18
35
|
# @return [Array<Yes::Core::Event>]
|
|
19
36
|
def safe_read(stream)
|
|
@@ -21,6 +38,61 @@ module Yes
|
|
|
21
38
|
rescue PgEventstore::StreamNotFoundError
|
|
22
39
|
[]
|
|
23
40
|
end
|
|
41
|
+
|
|
42
|
+
alias read_events safe_read
|
|
43
|
+
|
|
44
|
+
# Creates events from a block and appends them to the eventstore
|
|
45
|
+
#
|
|
46
|
+
# @yield block that returns an array of event attribute hashes
|
|
47
|
+
# @yieldreturn [Array<Hash>] each hash should have :context, :aggregate, :event, :data keys
|
|
48
|
+
# @return [void]
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# given_events do
|
|
52
|
+
# [{ context: 'MyContext', aggregate: 'MyAggregate', event: 'Created', data: { id: '123' } }]
|
|
53
|
+
# end
|
|
54
|
+
def given_events(&)
|
|
55
|
+
events_data = yield
|
|
56
|
+
events_data.each do |event_data|
|
|
57
|
+
event = event_instance(event_data)
|
|
58
|
+
stream = event_stream(event_data)
|
|
59
|
+
append_event(stream, event)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a Yes::Core::Event from a hash of event attributes
|
|
64
|
+
#
|
|
65
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate, :event, :data keys
|
|
66
|
+
# @return [Yes::Core::Event]
|
|
67
|
+
def event_instance(event_attrs)
|
|
68
|
+
Yes::Core::Event.new(
|
|
69
|
+
type: event_type(event_attrs),
|
|
70
|
+
data: (event_attrs[:data] || {}).with_indifferent_access
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Builds a PgEventstore::Stream from event attributes
|
|
75
|
+
#
|
|
76
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate, :data keys
|
|
77
|
+
# @return [PgEventstore::Stream]
|
|
78
|
+
def event_stream(event_attrs)
|
|
79
|
+
return event_attrs[:stream] if event_attrs[:stream]
|
|
80
|
+
|
|
81
|
+
PgEventstore::Stream.new(
|
|
82
|
+
context: event_attrs[:context],
|
|
83
|
+
stream_name: event_attrs[:aggregate],
|
|
84
|
+
stream_id: event_attrs[:data].first.last
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Constructs an event type string from event attributes
|
|
89
|
+
#
|
|
90
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate/:subject, :event keys
|
|
91
|
+
# @return [String]
|
|
92
|
+
def event_type(event_attrs)
|
|
93
|
+
aggregate_or_subject = event_attrs[:aggregate] || event_attrs[:subject]
|
|
94
|
+
"#{event_attrs[:context]}::#{aggregate_or_subject}#{event_attrs[:event]}"
|
|
95
|
+
end
|
|
24
96
|
end
|
|
25
97
|
end
|
|
26
98
|
end
|
|
@@ -3,3 +3,6 @@
|
|
|
3
3
|
require_relative 'test_support/event_helpers'
|
|
4
4
|
require_relative 'test_support/jwt_helpers'
|
|
5
5
|
require_relative 'test_support/test_helper'
|
|
6
|
+
require_relative 'test_support/aggregate/command_test_dsl'
|
|
7
|
+
require_relative 'test_support/aggregate/shared_examples'
|
|
8
|
+
require_relative 'test_support/aggregate/matchers'
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
module Yes
|
|
4
4
|
module Core
|
|
5
5
|
module Utils
|
|
6
|
-
# Provides convenient shortcuts for accessing aggregate classes in Rails console
|
|
7
|
-
# @example
|
|
8
|
-
# # Instead of: ApprenticeshipPresentation::
|
|
9
|
-
# # Use: AP::
|
|
6
|
+
# Provides convenient shortcuts for accessing aggregate classes in Rails console.
|
|
7
|
+
# @example Multi-capital subjects use capitals-only abbreviations
|
|
8
|
+
# # Instead of: ApprenticeshipPresentation::ContactInfo::Aggregate.new(id)
|
|
9
|
+
# # Use: AP::CI.new(id)
|
|
10
|
+
# @example Single-capital subjects keep the full name
|
|
11
|
+
# # Instead of: TaskFlow::Board::Aggregate.new(id)
|
|
12
|
+
# # Use: TF::Board.new(id)
|
|
10
13
|
class AggregateShortcuts
|
|
11
14
|
class << self
|
|
12
|
-
# Load aggregate shortcuts in Rails console
|
|
13
|
-
# Creates
|
|
15
|
+
# Load aggregate shortcuts in Rails console.
|
|
16
|
+
# Creates fresh shortcut modules (e.g. TF) and assigns aggregate classes
|
|
17
|
+
# as constants on them. Shortcut modules are NOT aliases of the real
|
|
18
|
+
# context modules, so shortcut constants cannot collide with the
|
|
19
|
+
# aggregates' own namespace modules.
|
|
14
20
|
def load!
|
|
15
21
|
return unless Yes::Core.configuration.aggregate_shortcuts
|
|
16
22
|
|
|
@@ -102,19 +108,22 @@ module Yes
|
|
|
102
108
|
context_abbr = abbreviate_context(agg[:context])
|
|
103
109
|
subject_abbr = abbreviate_subject(agg[:subject])
|
|
104
110
|
|
|
105
|
-
#
|
|
111
|
+
# Build (or reuse) a fresh container module for the context shortcut.
|
|
112
|
+
# We deliberately do NOT alias the real context module: doing so would
|
|
113
|
+
# mean shortcut constants (e.g. TF::Board) collide with the real
|
|
114
|
+
# namespace modules of the aggregates themselves (TaskFlow::Board).
|
|
106
115
|
unless context_modules[context_abbr]
|
|
107
116
|
if Object.const_defined?(context_abbr)
|
|
108
117
|
Rails.logger.warn("Shortcut conflict: #{context_abbr} already defined, skipping #{agg[:context]}")
|
|
109
118
|
next
|
|
110
119
|
end
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
Object.const_set(context_abbr,
|
|
114
|
-
context_modules[context_abbr] =
|
|
121
|
+
shortcut_module = Module.new
|
|
122
|
+
Object.const_set(context_abbr, shortcut_module)
|
|
123
|
+
context_modules[context_abbr] = shortcut_module
|
|
115
124
|
end
|
|
116
125
|
|
|
117
|
-
# Create subject constant within
|
|
126
|
+
# Create subject constant within the shortcut container.
|
|
118
127
|
context_mod = context_modules[context_abbr]
|
|
119
128
|
if context_mod.const_defined?(subject_abbr)
|
|
120
129
|
Rails.logger.warn("Shortcut conflict: #{context_abbr}::#{subject_abbr} already defined")
|
|
@@ -140,12 +149,15 @@ module Yes
|
|
|
140
149
|
def abbreviate_subject(subject)
|
|
141
150
|
return @subject_overrides[subject] if @subject_overrides[subject]
|
|
142
151
|
|
|
143
|
-
#
|
|
152
|
+
# Multi-capital CamelCase names get a capitals-only abbreviation
|
|
153
|
+
# (ContactInfo → CI). Single-capital names (Task, Board, Location)
|
|
154
|
+
# use the full subject name to avoid awkward truncations like
|
|
155
|
+
# "Boar" or shortcut collisions when the truncation matches the
|
|
156
|
+
# subject's own namespace module (e.g. TaskFlow::Task).
|
|
144
157
|
capitals = subject.scan(/[A-Z]/).join
|
|
145
158
|
return capitals if capitals.length > 1
|
|
146
159
|
|
|
147
|
-
|
|
148
|
-
subject[0..3]
|
|
160
|
+
subject
|
|
149
161
|
end
|
|
150
162
|
|
|
151
163
|
def define_helper_method
|
|
@@ -68,7 +68,7 @@ module Yes
|
|
|
68
68
|
def build_event(command_name:, payload:, metadata: {})
|
|
69
69
|
event_class = Yes::Core.configuration.event_classes_for_command(context, aggregate, command_name).first
|
|
70
70
|
event_class.new(
|
|
71
|
-
type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata)}#{event_class.name.demodulize}",
|
|
71
|
+
type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata, context:)}#{event_class.name.demodulize}",
|
|
72
72
|
data: payload,
|
|
73
73
|
metadata:
|
|
74
74
|
)
|
|
@@ -83,7 +83,7 @@ module Yes
|
|
|
83
83
|
def build_stream(context: @context, name: @aggregate, id: @aggregate_id, metadata: {})
|
|
84
84
|
PgEventstore::Stream.new(
|
|
85
85
|
context:,
|
|
86
|
-
stream_name: aggregate_name_with_draft_suffix(name, metadata),
|
|
86
|
+
stream_name: aggregate_name_with_draft_suffix(name, metadata, context:),
|
|
87
87
|
stream_id: id
|
|
88
88
|
)
|
|
89
89
|
end
|
|
@@ -209,16 +209,33 @@ module Yes
|
|
|
209
209
|
payload.merge(locale: I18n.locale.to_s)
|
|
210
210
|
end
|
|
211
211
|
|
|
212
|
-
# Builds the aggregate name with the draft suffix
|
|
212
|
+
# Builds the aggregate name with the draft suffix.
|
|
213
|
+
#
|
|
214
|
+
# When the aggregate class is configured with `draftable changes_read_model:` (explicitly),
|
|
215
|
+
# the camelized changes_read_model is used as the stream / event-type prefix so the DSL
|
|
216
|
+
# config decides where draft events land. This lets an aggregate share an edit-template
|
|
217
|
+
# stream with a sibling (e.g. `Recruiter` writes to `UserEditTemplate` via
|
|
218
|
+
# `changes_read_model: :user_edit_template`).
|
|
219
|
+
#
|
|
220
|
+
# For non-draftable aggregates, and for draftable aggregates that rely on the default
|
|
221
|
+
# `<read_model>_change` changes_read_model name, the legacy hard-coded `<Aggregate>Draft`
|
|
222
|
+
# / `<Aggregate>EditTemplate` suffix is used.
|
|
213
223
|
#
|
|
214
224
|
# @param aggregate_name [String] The name of the aggregate
|
|
215
225
|
# @param metadata [Hash] The command metadata
|
|
216
|
-
# @
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
aggregate_name
|
|
226
|
+
# @param context [String] The aggregate's context (defaults to @context)
|
|
227
|
+
# @return [String] The stream / event-type name component
|
|
228
|
+
def aggregate_name_with_draft_suffix(aggregate_name, metadata = {}, context: @context)
|
|
229
|
+
return aggregate_name unless metadata&.dig(:draft) || metadata&.dig(:edit_template_command)
|
|
230
|
+
|
|
231
|
+
klass = "#{context}::#{aggregate_name}::Aggregate".safe_constantize
|
|
232
|
+
if klass.respond_to?(:_changes_read_model_explicit) && klass._changes_read_model_explicit
|
|
233
|
+
klass.changes_read_model_name.camelize
|
|
234
|
+
elsif metadata&.dig(:edit_template_command)
|
|
235
|
+
"#{aggregate_name}EditTemplate"
|
|
236
|
+
else
|
|
237
|
+
"#{aggregate_name}Draft"
|
|
238
|
+
end
|
|
222
239
|
end
|
|
223
240
|
end
|
|
224
241
|
end
|
data/lib/yes/core/version.rb
CHANGED
data/lib/yes/core.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Yes
|
|
|
19
19
|
loader.push_dir(File.expand_path('..', __dir__))
|
|
20
20
|
loader.ignore("#{__dir__}/core/version.rb")
|
|
21
21
|
loader.ignore("#{__dir__}/core/test_support")
|
|
22
|
+
loader.ignore("#{__dir__}/core/test_support.rb")
|
|
22
23
|
loader.collapse("#{__dir__}/core/models")
|
|
23
24
|
loader.setup
|
|
24
25
|
loader
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yes-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nico Ritsche
|
|
@@ -259,6 +259,7 @@ files:
|
|
|
259
259
|
- lib/yes/core/generators/read_models/templates/migration.rb.erb
|
|
260
260
|
- lib/yes/core/generators/read_models/update_generator.rb
|
|
261
261
|
- lib/yes/core/jobs/read_model_recovery_job.rb
|
|
262
|
+
- lib/yes/core/middlewares.rb
|
|
262
263
|
- lib/yes/core/middlewares/encryptor.rb
|
|
263
264
|
- lib/yes/core/middlewares/timestamp.rb
|
|
264
265
|
- lib/yes/core/middlewares/with_indifferent_access.rb
|
|
@@ -281,6 +282,9 @@ files:
|
|
|
281
282
|
- lib/yes/core/serializer.rb
|
|
282
283
|
- lib/yes/core/subscriptions.rb
|
|
283
284
|
- lib/yes/core/test_support.rb
|
|
285
|
+
- lib/yes/core/test_support/aggregate/command_test_dsl.rb
|
|
286
|
+
- lib/yes/core/test_support/aggregate/matchers.rb
|
|
287
|
+
- lib/yes/core/test_support/aggregate/shared_examples.rb
|
|
284
288
|
- lib/yes/core/test_support/event_helpers.rb
|
|
285
289
|
- lib/yes/core/test_support/jwt_helpers.rb
|
|
286
290
|
- lib/yes/core/test_support/subscriptions_helper.rb
|