sequent 3.2.2 → 4.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/lib/notices.rb +4 -0
- data/lib/sequent.rb +3 -0
- data/lib/sequent/application_record.rb +7 -0
- data/lib/sequent/configuration.rb +13 -0
- data/lib/sequent/core/aggregate_repository.rb +7 -1
- data/lib/sequent/core/command.rb +13 -2
- data/lib/sequent/core/command_record.rb +5 -2
- data/lib/sequent/core/command_service.rb +28 -12
- data/lib/sequent/core/event_publisher.rb +4 -0
- data/lib/sequent/core/event_record.rb +2 -1
- data/lib/sequent/core/event_store.rb +23 -4
- data/lib/sequent/core/helpers/attribute_support.rb +28 -7
- data/lib/sequent/core/helpers/mergable.rb +1 -0
- data/lib/sequent/core/persistors/active_record_persistor.rb +1 -1
- data/lib/sequent/core/persistors/replay_optimized_postgres_persistor.rb +2 -2
- data/lib/sequent/core/projector.rb +23 -1
- data/lib/sequent/core/stream_record.rb +1 -1
- data/lib/sequent/core/transactions/active_record_transaction_provider.rb +6 -4
- data/lib/sequent/generator.rb +1 -4
- data/lib/sequent/generator/generator.rb +4 -0
- data/lib/sequent/generator/project.rb +1 -1
- data/lib/sequent/generator/template_project/Gemfile +1 -1
- data/lib/sequent/generator/template_project/app/records/post_record.rb +1 -1
- data/lib/sequent/generator/template_project/spec/app/projectors/post_projector_spec.rb +1 -1
- data/lib/sequent/generator/template_project/spec/lib/post/post_command_handler_spec.rb +1 -1
- data/lib/sequent/migrations/executor.rb +78 -0
- data/lib/sequent/migrations/functions.rb +76 -0
- data/lib/sequent/migrations/migrations.rb +1 -0
- data/lib/sequent/migrations/planner.rb +118 -0
- data/lib/sequent/migrations/projectors.rb +6 -5
- data/lib/sequent/migrations/sql.rb +17 -0
- data/lib/sequent/migrations/view_schema.rb +74 -73
- data/lib/sequent/rake/migration_tasks.rb +2 -2
- data/lib/sequent/rake/tasks.rb +1 -1
- data/lib/sequent/sequent.rb +5 -1
- data/lib/sequent/support/database.rb +11 -6
- data/lib/sequent/test/command_handler_helpers.rb +4 -0
- data/lib/sequent/util/dry_run.rb +191 -0
- data/lib/sequent/util/skip_if_already_processing.rb +19 -5
- data/lib/sequent/util/util.rb +1 -0
- data/lib/version.rb +1 -1
- metadata +77 -36
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3322a4b950e847a5818555b2718df70e2b81a8e398074309a0b58c2a8d332ce5
|
4
|
+
data.tar.gz: 6136697a8a9596e999fabf3814509428b06188a0ac577fa3443a80ec190cbdd7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f8ce675ae0a16274630066192086fb8c46bb0306e9280fd4b8901500af4a7af56844c5d6c40cd0f45a87342c33a4faeb2eee48f0dc0fb05a0a09046a2dea7414
|
7
|
+
data.tar.gz: e750e54d72de1c8641a513839c675adeb32fbeaa3435528a8aca755a951a7572d696e9348b7594e104f044978a0e9780f8e080ff1ad5e4ea9b7cc203fac6fee2
|
data/lib/notices.rb
ADDED
data/lib/sequent.rb
CHANGED
@@ -13,6 +13,7 @@ module Sequent
|
|
13
13
|
|
14
14
|
DEFAULT_MIGRATION_SQL_FILES_DIRECTORY = 'db/tables'
|
15
15
|
DEFAULT_DATABASE_CONFIG_DIRECTORY = 'db'
|
16
|
+
DEFAULT_DATABASE_SCHEMA_DIRECTORY = 'db'
|
16
17
|
|
17
18
|
DEFAULT_VIEW_SCHEMA_NAME = 'view_schema'
|
18
19
|
DEFAULT_EVENT_STORE_SCHEMA_NAME= 'sequent_schema'
|
@@ -26,6 +27,10 @@ module Sequent
|
|
26
27
|
|
27
28
|
DEFAULT_EVENT_RECORD_HOOKS_CLASS = Sequent::Core::EventRecordHooks
|
28
29
|
|
30
|
+
DEFAULT_STRICT_CHECK_ATTRIBUTES_ON_APPLY_EVENTS = false
|
31
|
+
|
32
|
+
DEFAULT_ERROR_LOCALE_RESOLVER = -> { I18n.locale || :en }
|
33
|
+
|
29
34
|
attr_accessor :aggregate_repository
|
30
35
|
|
31
36
|
attr_accessor :event_store,
|
@@ -49,14 +54,19 @@ module Sequent
|
|
49
54
|
|
50
55
|
attr_accessor :logger
|
51
56
|
|
57
|
+
attr_accessor :error_locale_resolver
|
58
|
+
|
52
59
|
attr_accessor :migration_sql_files_directory,
|
53
60
|
:view_schema_name,
|
54
61
|
:offline_replay_persistor_class,
|
55
62
|
:online_replay_persistor_class,
|
56
63
|
:number_of_replay_processes,
|
57
64
|
:database_config_directory,
|
65
|
+
:database_schema_directory,
|
58
66
|
:event_store_schema_name
|
59
67
|
|
68
|
+
attr_accessor :strict_check_attributes_on_apply_events
|
69
|
+
|
60
70
|
attr_reader :migrations_class_name,
|
61
71
|
:versions_table_name,
|
62
72
|
:replayed_ids_table_name
|
@@ -101,8 +111,11 @@ module Sequent
|
|
101
111
|
self.offline_replay_persistor_class = DEFAULT_OFFLINE_REPLAY_PERSISTOR_CLASS
|
102
112
|
self.online_replay_persistor_class = DEFAULT_ONLINE_REPLAY_PERSISTOR_CLASS
|
103
113
|
self.database_config_directory = DEFAULT_DATABASE_CONFIG_DIRECTORY
|
114
|
+
self.database_schema_directory = DEFAULT_DATABASE_SCHEMA_DIRECTORY
|
115
|
+
self.strict_check_attributes_on_apply_events = DEFAULT_STRICT_CHECK_ATTRIBUTES_ON_APPLY_EVENTS
|
104
116
|
|
105
117
|
self.logger = Logger.new(STDOUT).tap {|l| l.level = Logger::INFO }
|
118
|
+
self.error_locale_resolver = DEFAULT_ERROR_LOCALE_RESOLVER
|
106
119
|
end
|
107
120
|
|
108
121
|
def replayed_ids_table_name=(table_name)
|
@@ -100,11 +100,17 @@ module Sequent
|
|
100
100
|
##
|
101
101
|
# Returns whether the event store has an aggregate with the given id
|
102
102
|
def contains_aggregate?(aggregate_id)
|
103
|
-
Sequent.configuration.event_store.stream_exists?(aggregate_id)
|
103
|
+
Sequent.configuration.event_store.stream_exists?(aggregate_id) &&
|
104
|
+
Sequent.configuration.event_store.events_exists?(aggregate_id)
|
104
105
|
end
|
105
106
|
|
106
107
|
# Gets all uncommitted_events from the 'registered' aggregates
|
107
108
|
# and stores them in the event store.
|
109
|
+
#
|
110
|
+
# The events given to the EventStore are ordered in loading order
|
111
|
+
# of the different AggregateRoot's. So Events are stored
|
112
|
+
# (and therefore published) in order in which they are `apply`-ed per AggregateRoot.
|
113
|
+
#
|
108
114
|
# The command is 'attached' for traceability purpose so we can see
|
109
115
|
# which command resulted in which events.
|
110
116
|
#
|
data/lib/sequent/core/command.rb
CHANGED
@@ -7,7 +7,15 @@ require_relative 'helpers/mergable'
|
|
7
7
|
|
8
8
|
module Sequent
|
9
9
|
module Core
|
10
|
-
#
|
10
|
+
#
|
11
|
+
# Base class for all Command's.
|
12
|
+
#
|
13
|
+
# Commands form the API of your domain. They are
|
14
|
+
# simple data objects with descriptive names
|
15
|
+
# of what they want to achieve. E.g. `SendInvoice`.
|
16
|
+
#
|
17
|
+
# BaseCommand uses `ActiveModel::Validations` for
|
18
|
+
# validations
|
11
19
|
class BaseCommand
|
12
20
|
include ActiveModel::Validations,
|
13
21
|
Sequent::Core::Helpers::Copyable,
|
@@ -40,6 +48,9 @@ module Sequent
|
|
40
48
|
end
|
41
49
|
end
|
42
50
|
|
51
|
+
#
|
52
|
+
# Utility class containing all subclasses of BaseCommand
|
53
|
+
#
|
43
54
|
class Commands
|
44
55
|
class << self
|
45
56
|
def commands
|
@@ -60,7 +71,7 @@ module Sequent
|
|
60
71
|
end
|
61
72
|
end
|
62
73
|
|
63
|
-
# Most commonly used
|
74
|
+
# Most commonly used Command
|
64
75
|
# Command can be instantiated just by using:
|
65
76
|
#
|
66
77
|
# Command.new(aggregate_id: "1", user_id: "joe")
|
@@ -32,7 +32,7 @@ module Sequent
|
|
32
32
|
end
|
33
33
|
|
34
34
|
# For storing Sequent::Core::Command in the database using active_record
|
35
|
-
class CommandRecord <
|
35
|
+
class CommandRecord < Sequent::ApplicationRecord
|
36
36
|
include SerializesCommand
|
37
37
|
|
38
38
|
self.table_name = "command_records"
|
@@ -42,7 +42,10 @@ module Sequent
|
|
42
42
|
validates_presence_of :command_type, :command_json
|
43
43
|
|
44
44
|
def parent
|
45
|
-
EventRecord
|
45
|
+
EventRecord
|
46
|
+
.where(aggregate_id: event_aggregate_id, sequence_number: event_sequence_number)
|
47
|
+
.where('event_type != ?', Sequent::Core::SnapshotEvent.name)
|
48
|
+
.first
|
46
49
|
end
|
47
50
|
|
48
51
|
def children
|
@@ -4,23 +4,29 @@ require_relative 'current_event'
|
|
4
4
|
module Sequent
|
5
5
|
module Core
|
6
6
|
#
|
7
|
-
# Single point in the application
|
8
|
-
#
|
7
|
+
# Single point in the application to get something done in Sequent.
|
8
|
+
# The CommandService handles all subclasses Sequent::Core::BaseCommand. Most common
|
9
|
+
# use is to subclass `Sequent::Command`.
|
9
10
|
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
11
|
+
# The CommandService is available via the shortcut method `Sequent.command_service`
|
12
|
+
#
|
13
|
+
# To use the CommandService please use:
|
14
|
+
#
|
15
|
+
# Sequent.command_service.execute_commands(...)
|
15
16
|
#
|
16
17
|
class CommandService
|
18
|
+
#
|
17
19
|
# Executes the given commands in a single transactional block as implemented by the +transaction_provider+
|
18
20
|
#
|
19
|
-
# For each
|
21
|
+
# For each Command:
|
22
|
+
#
|
23
|
+
# * Validate command
|
24
|
+
# * Call Sequent::CommandHandler's listening to the given Command
|
25
|
+
# * Store and publish Events
|
26
|
+
# * Any new Command's (from e.g. workflows) are queued for processing in the same transaction
|
27
|
+
#
|
28
|
+
# At the end the transaction is committed and the AggregateRepository's Unit of Work is cleared.
|
20
29
|
#
|
21
|
-
# * All filters are executed. Any exception raised will rollback the transaction and propagate up
|
22
|
-
# * If the command is valid all +command_handlers+ that +handles_message?+ is invoked
|
23
|
-
# * The +repository+ commits the command and all uncommitted_events resulting from the command
|
24
30
|
def execute_commands(*commands)
|
25
31
|
commands.each do |command|
|
26
32
|
if command.respond_to?(:event_aggregate_id) && CurrentEvent.current
|
@@ -33,6 +39,7 @@ module Sequent
|
|
33
39
|
end
|
34
40
|
|
35
41
|
def remove_event_handler(clazz)
|
42
|
+
warn "[DEPRECATION] `remove_event_handler` is deprecated"
|
36
43
|
event_store.remove_event_handler(clazz)
|
37
44
|
end
|
38
45
|
|
@@ -45,6 +52,7 @@ module Sequent
|
|
45
52
|
while(!command_queue.empty?) do
|
46
53
|
process_command(command_queue.pop)
|
47
54
|
end
|
55
|
+
Sequent::Util.done_processing(:command_service_process_commands)
|
48
56
|
end
|
49
57
|
ensure
|
50
58
|
command_queue.clear
|
@@ -54,9 +62,16 @@ module Sequent
|
|
54
62
|
end
|
55
63
|
|
56
64
|
def process_command(command)
|
65
|
+
fail ArgumentError, 'command is required' if command.nil?
|
66
|
+
|
67
|
+
Sequent.logger.debug("[CommandService] Processing command #{command.class}")
|
68
|
+
|
57
69
|
filters.each { |filter| filter.execute(command) }
|
58
70
|
|
59
|
-
|
71
|
+
I18n.with_locale(Sequent.configuration.error_locale_resolver.call) do
|
72
|
+
raise CommandNotValid.new(command) unless command.valid?
|
73
|
+
end
|
74
|
+
|
60
75
|
parsed_command = command.parse_attrs_to_correct_types
|
61
76
|
command_handlers.select { |h| h.class.handles_message?(parsed_command) }.each { |h| h.handle_message parsed_command }
|
62
77
|
repository.commit(parsed_command)
|
@@ -89,6 +104,7 @@ module Sequent
|
|
89
104
|
|
90
105
|
# Raised when BaseCommand.valid? returns false
|
91
106
|
class CommandNotValid < ArgumentError
|
107
|
+
attr_reader :command
|
92
108
|
|
93
109
|
def initialize(command)
|
94
110
|
@command = command
|
@@ -46,6 +46,10 @@ module Sequent
|
|
46
46
|
end
|
47
47
|
|
48
48
|
def process_event(event)
|
49
|
+
fail ArgumentError, 'event is required' if event.nil?
|
50
|
+
|
51
|
+
Sequent.logger.debug("[EventPublisher] Publishing event #{event.class}")
|
52
|
+
|
49
53
|
configuration.event_handlers.each do |handler|
|
50
54
|
begin
|
51
55
|
handler.handle_message event
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'active_record'
|
2
2
|
require_relative 'sequent_oj'
|
3
|
+
require_relative '../application_record.rb'
|
3
4
|
|
4
5
|
module Sequent
|
5
6
|
module Core
|
@@ -72,7 +73,7 @@ module Sequent
|
|
72
73
|
end
|
73
74
|
end
|
74
75
|
|
75
|
-
class EventRecord <
|
76
|
+
class EventRecord < Sequent::ApplicationRecord
|
76
77
|
include SerializesEvent
|
77
78
|
|
78
79
|
self.table_name = "event_records"
|
@@ -33,10 +33,18 @@ module Sequent
|
|
33
33
|
# Stores the events in the EventStore and publishes the events
|
34
34
|
# to the registered event_handlers.
|
35
35
|
#
|
36
|
-
#
|
37
|
-
#
|
36
|
+
# The events are published according to the order in
|
37
|
+
# the tail of the given `streams_with_events` array pair.
|
38
|
+
#
|
39
|
+
# @param command The command that caused the Events
|
40
|
+
# @param streams_with_events is an enumerable of pairs from
|
41
|
+
# `StreamRecord` to arrays ordered uncommitted `Event`s.
|
38
42
|
#
|
39
43
|
def commit_events(command, streams_with_events)
|
44
|
+
fail ArgumentError, "command is required" if command.nil?
|
45
|
+
|
46
|
+
Sequent.logger.debug("[EventStore] Committing events for command #{command.class}")
|
47
|
+
|
40
48
|
store_events(command, streams_with_events)
|
41
49
|
publish_events(streams_with_events.flat_map { |_, events| events })
|
42
50
|
end
|
@@ -80,6 +88,9 @@ ORDER BY sequence_number ASC, (CASE event_type WHEN #{quote Sequent.configuratio
|
|
80
88
|
Sequent.configuration.stream_record_class.exists?(aggregate_id: aggregate_id)
|
81
89
|
end
|
82
90
|
|
91
|
+
def events_exists?(aggregate_id)
|
92
|
+
Sequent.configuration.event_record_class.exists?(aggregate_id: aggregate_id)
|
93
|
+
end
|
83
94
|
##
|
84
95
|
# Replays all events in the event store to the registered event_handlers.
|
85
96
|
#
|
@@ -158,7 +169,15 @@ SELECT aggregate_id
|
|
158
169
|
private
|
159
170
|
|
160
171
|
def column_names
|
161
|
-
@column_names ||= Sequent
|
172
|
+
@column_names ||= Sequent
|
173
|
+
.configuration
|
174
|
+
.event_record_class
|
175
|
+
.column_names
|
176
|
+
.reject { |c| c == primary_key_event_records }
|
177
|
+
end
|
178
|
+
|
179
|
+
def primary_key_event_records
|
180
|
+
@primary_key_event_records ||= Sequent.configuration.event_record_class.primary_key
|
162
181
|
end
|
163
182
|
|
164
183
|
def deserialize_event(event_hash)
|
@@ -200,7 +219,7 @@ SELECT aggregate_id
|
|
200
219
|
.join(',')
|
201
220
|
columns = column_names.map { |c| connection.quote_column_name(c) }.join(',')
|
202
221
|
sql = %Q{insert into #{connection.quote_table_name(Sequent.configuration.event_record_class.table_name)} (#{columns}) values #{values}}
|
203
|
-
Sequent.configuration.event_record_class.connection.insert(sql)
|
222
|
+
Sequent.configuration.event_record_class.connection.insert(sql, nil, primary_key_event_records)
|
204
223
|
rescue ActiveRecord::RecordNotUnique
|
205
224
|
fail OptimisticLockingError.new
|
206
225
|
end
|
@@ -9,17 +9,33 @@ require_relative 'association_validator'
|
|
9
9
|
module Sequent
|
10
10
|
module Core
|
11
11
|
module Helpers
|
12
|
-
# Provides functionality for defining attributes with their types
|
12
|
+
# Provides functionality for defining attributes with their types.
|
13
13
|
#
|
14
|
-
# Since our Commands and ValueObjects are not backed by a database like e.g.
|
14
|
+
# Since our Commands and ValueObjects are not backed by a database like e.g. Rails
|
15
15
|
# we can not infer their types. We need the types to be able to parse from and to json.
|
16
|
-
# We could have stored te type information in the json, but we didn't.
|
17
|
-
#
|
18
16
|
# You typically do not need to include this module in your classes. If you extend from
|
19
|
-
# Sequent::
|
17
|
+
# Sequent::ValueObject, Sequent::Event or Sequent::Command you will
|
20
18
|
# get this functionality for free.
|
21
19
|
#
|
20
|
+
# Example:
|
21
|
+
#
|
22
|
+
# attrs name: String, age: Integer, born: Date
|
23
|
+
#
|
24
|
+
# Currently Sequent supports the following types:
|
25
|
+
#
|
26
|
+
# - String
|
27
|
+
# - Integer
|
28
|
+
# - Boolean
|
29
|
+
# - Date
|
30
|
+
# - DateTime
|
31
|
+
# - Subclasses of Sequent::ValueObject
|
32
|
+
# - Lists defined as `array(String)`
|
33
|
+
# - BigDecimal
|
34
|
+
# - Sequent::Secret
|
35
|
+
#
|
22
36
|
module AttributeSupport
|
37
|
+
class UnknownAttributeError < StandardError; end
|
38
|
+
|
23
39
|
# module containing class methods to be added
|
24
40
|
module ClassMethods
|
25
41
|
|
@@ -60,6 +76,7 @@ module Sequent
|
|
60
76
|
class_eval <<EOS
|
61
77
|
def update_all_attributes(attrs)
|
62
78
|
super if defined?(super)
|
79
|
+
ensure_known_attributes(attrs)
|
63
80
|
#{@types.map { |attribute, _|
|
64
81
|
"@#{attribute} = attrs[:#{attribute}]"
|
65
82
|
}.join("\n ")}
|
@@ -157,9 +174,13 @@ EOS
|
|
157
174
|
prefix ? HashWithIndifferentAccess[result.map { |k, v| ["#{prefix}_#{k}", v] }] : result
|
158
175
|
end
|
159
176
|
|
160
|
-
|
161
|
-
|
177
|
+
def ensure_known_attributes(attrs)
|
178
|
+
return unless Sequent.configuration.strict_check_attributes_on_apply_events
|
162
179
|
|
180
|
+
unknowns = attrs.keys.map(&:to_s) - self.class.types.keys.map(&:to_s)
|
181
|
+
raise UnknownAttributeError.new("#{self.class.name} does not specify attrs: #{unknowns.join(", ")}") if unknowns.any?
|
182
|
+
end
|
183
|
+
end
|
163
184
|
end
|
164
185
|
end
|
165
186
|
end
|
@@ -8,6 +8,7 @@ module Sequent
|
|
8
8
|
module Mergable
|
9
9
|
|
10
10
|
def merge!(attrs = {})
|
11
|
+
warn "[DEPRECATION] `merge!` is deprecated. Please use `copy` instead. This method will no longer be included in the next version of Sequent. You can still use it but you will have to include the module `Sequent::Core::Helpers::Mergable` yourself."
|
11
12
|
attrs.each do |name, value|
|
12
13
|
self.send("#{name}=", value)
|
13
14
|
end
|
@@ -307,7 +307,7 @@ module Sequent
|
|
307
307
|
end
|
308
308
|
|
309
309
|
buf = ''
|
310
|
-
conn =
|
310
|
+
conn = Sequent::ApplicationRecord.connection.raw_connection
|
311
311
|
copy_data = StringIO.new csv.string
|
312
312
|
conn.transaction do
|
313
313
|
conn.copy_data("COPY #{clazz.table_name} (#{column_names.join(",")}) FROM STDIN WITH csv") do
|
@@ -346,7 +346,7 @@ module Sequent
|
|
346
346
|
private
|
347
347
|
|
348
348
|
def cast_value_to_column_type(clazz, column_name, record)
|
349
|
-
|
349
|
+
Sequent::ApplicationRecord.connection.type_cast(record[column_name.to_sym], @column_cache[clazz.name][column_name])
|
350
350
|
end
|
351
351
|
end
|
352
352
|
end
|
@@ -11,7 +11,26 @@ module Sequent
|
|
11
11
|
end
|
12
12
|
|
13
13
|
def managed_tables
|
14
|
-
@managed_tables
|
14
|
+
@managed_tables || managed_tables_from_superclass
|
15
|
+
end
|
16
|
+
|
17
|
+
def manages_no_tables
|
18
|
+
@manages_no_tables = true
|
19
|
+
manages_tables *[]
|
20
|
+
end
|
21
|
+
|
22
|
+
def manages_no_tables?
|
23
|
+
!!@manages_no_tables || manages_no_tables_from_superclass?
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def managed_tables_from_superclass
|
29
|
+
self.superclass.managed_tables if self.superclass.respond_to?(:managed_tables)
|
30
|
+
end
|
31
|
+
|
32
|
+
def manages_no_tables_from_superclass?
|
33
|
+
self.superclass.manages_no_tables? if self.superclass.respond_to?(:manages_no_tables?)
|
15
34
|
end
|
16
35
|
end
|
17
36
|
|
@@ -96,7 +115,10 @@ module Sequent
|
|
96
115
|
:commit
|
97
116
|
|
98
117
|
private
|
118
|
+
|
99
119
|
def ensure_valid!
|
120
|
+
return if self.class.manages_no_tables?
|
121
|
+
|
100
122
|
fail "A Projector must manage at least one table. Did you forget to add `managed_tables` to #{self.class.name}?" if self.class.managed_tables.nil? || self.class.managed_tables.empty?
|
101
123
|
end
|
102
124
|
end
|