event_rail 0.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/CONTRIBUTING.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +364 -0
- data/SECURITY.md +3 -0
- data/lib/event_rail/contract.rb +44 -0
- data/lib/event_rail/current.rb +95 -0
- data/lib/event_rail/data.rb +4 -0
- data/lib/event_rail/envelope.rb +123 -0
- data/lib/event_rail/errors.rb +196 -0
- data/lib/event_rail/event.rb +258 -0
- data/lib/event_rail/internal/attribute_record.rb +272 -0
- data/lib/event_rail/internal/context.rb +32 -0
- data/lib/event_rail/internal/contract_index.rb +28 -0
- data/lib/event_rail/internal/event_serializer.rb +148 -0
- data/lib/event_rail/internal/execution.rb +86 -0
- data/lib/event_rail/internal/extensions.rb +69 -0
- data/lib/event_rail/internal/identity.rb +90 -0
- data/lib/event_rail/internal/notifications.rb +42 -0
- data/lib/event_rail/internal/portable_value.rb +108 -0
- data/lib/event_rail/internal/registry.rb +224 -0
- data/lib/event_rail/internal/stamping.rb +185 -0
- data/lib/event_rail/internal/subscriber_execution.rb +61 -0
- data/lib/event_rail/internal/timestamp.rb +85 -0
- data/lib/event_rail/internal/transaction.rb +33 -0
- data/lib/event_rail/internal/types.rb +441 -0
- data/lib/event_rail/job_context.rb +124 -0
- data/lib/event_rail/limits.rb +31 -0
- data/lib/event_rail/metadata.rb +94 -0
- data/lib/event_rail/portable_type.rb +35 -0
- data/lib/event_rail/publication.rb +33 -0
- data/lib/event_rail/publish.rb +88 -0
- data/lib/event_rail/railtie.rb +15 -0
- data/lib/event_rail/subscriptions.rb +59 -0
- data/lib/event_rail/version.rb +3 -0
- data/lib/event_rail.rb +52 -0
- metadata +184 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module EventRail
|
|
4
|
+
module Internal
|
|
5
|
+
# Mixed into a job the first time it declares a subscription.
|
|
6
|
+
#
|
|
7
|
+
# A subscriber's logical message is the event it is handling, not the job that
|
|
8
|
+
# delivers it. That matters twice: lineage, so a follow-up event records the
|
|
9
|
+
# delivered event as its cause, and identity, so the follow-up derives the same ID on
|
|
10
|
+
# every delivery of that cause rather than a new one per delivery job.
|
|
11
|
+
module SubscriberExecution
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
# Registered after JobContext's, so it runs inside the installed context and the
|
|
16
|
+
# notification carries the lineage the subscriber actually ran with.
|
|
17
|
+
around_perform do |job, block|
|
|
18
|
+
ActiveSupport::Notifications.instrument(
|
|
19
|
+
"perform_subscriber.event_rail",
|
|
20
|
+
Notifications.payload_for(job.arguments.first).merge(job_class: job.class.name)
|
|
21
|
+
) { block.call }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def __event_rail_delivered_event__
|
|
27
|
+
expected = self.class.event_rail_subscriptions
|
|
28
|
+
candidate = arguments.first
|
|
29
|
+
|
|
30
|
+
unless arguments.length == 1 && expected.any? { |event_class| candidate.instance_of?(event_class) }
|
|
31
|
+
raise UnexpectedEventError.new(
|
|
32
|
+
"#{self.class} handles #{expected.map(&:to_s).sort.join(", ")} but received #{describe_arguments}",
|
|
33
|
+
job_class: self.class,
|
|
34
|
+
expected_event_classes: expected,
|
|
35
|
+
received_class: candidate.class
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A proposal has no identity, so there would be nothing to install as the
|
|
40
|
+
# logical message or to scope identity on. Rejecting only by class would let one
|
|
41
|
+
# through, because a proposal is an instance of the declared class.
|
|
42
|
+
unless candidate.stamped?
|
|
43
|
+
raise UnexpectedEventError.new(
|
|
44
|
+
"#{self.class} received an unpublished #{candidate.class} proposal, which carries no event identity",
|
|
45
|
+
job_class: self.class,
|
|
46
|
+
expected_event_classes: expected,
|
|
47
|
+
received_class: candidate.class
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
candidate
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def describe_arguments
|
|
55
|
+
return "no arguments" if arguments.empty?
|
|
56
|
+
|
|
57
|
+
arguments.map { |argument| argument.class.to_s }.join(", ")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "active_model/type"
|
|
2
|
+
|
|
3
|
+
module EventRail
|
|
4
|
+
module Internal
|
|
5
|
+
# One normalization for every instant EventRail stores, whether it arrives as
|
|
6
|
+
# metadata or as a declared attribute: an explicit offset is required, an offset
|
|
7
|
+
# outside the valid range is refused rather than quietly dropped, and the result
|
|
8
|
+
# is UTC at microsecond precision.
|
|
9
|
+
module Timestamp
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def cast(value, field: "timestamp")
|
|
13
|
+
normalize(value, field: field, error: InvalidMetadata)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
UTC_MICROSECOND_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ".freeze
|
|
17
|
+
|
|
18
|
+
# The one written spelling of an instant, shared by the portable projection, the
|
|
19
|
+
# job context entry, and the identity encoder, so the same moment never appears
|
|
20
|
+
# in two forms.
|
|
21
|
+
def written(value)
|
|
22
|
+
value.utc.strftime(UTC_MICROSECOND_FORMAT).freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize(value, field: "timestamp", error: InvalidMetadata)
|
|
26
|
+
return if value.nil?
|
|
27
|
+
|
|
28
|
+
check_offset!(value, field: field, error: error)
|
|
29
|
+
|
|
30
|
+
casted = ActiveModel::Type.lookup(:datetime).cast(value)
|
|
31
|
+
raise error, "#{field} is invalid" unless casted
|
|
32
|
+
|
|
33
|
+
microseconds = (epoch_seconds(casted) * 1_000_000).floor
|
|
34
|
+
Time.at(Rational(microseconds, 1_000_000)).utc.freeze
|
|
35
|
+
rescue Error
|
|
36
|
+
raise
|
|
37
|
+
rescue ArgumentError, TypeError, RangeError => cause
|
|
38
|
+
raise error, "#{field} is invalid: #{cause.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rails' :datetime cast returns a Time for a string and passes a Time,
|
|
42
|
+
# DateTime, or TimeWithZone through unchanged. Every one of those answers
|
|
43
|
+
# to_r except DateTime, whose only conversion is the to_time path Rails 7.2
|
|
44
|
+
# deprecates, so DateTime is reduced through its own formatted epoch instead.
|
|
45
|
+
def epoch_seconds(value)
|
|
46
|
+
case value
|
|
47
|
+
when DateTime then Rational(value.strftime("%s")) + value.sec_fraction
|
|
48
|
+
else value.to_r
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :epoch_seconds
|
|
52
|
+
|
|
53
|
+
# Ruby parses an out-of-range offset into a zone string with no usable offset,
|
|
54
|
+
# and Rails then treats the value as UTC. That silently records a different
|
|
55
|
+
# instant than the caller wrote, so the zone-without-offset case is refused.
|
|
56
|
+
def check_offset!(value, field:, error:)
|
|
57
|
+
case value
|
|
58
|
+
when Time, DateTime
|
|
59
|
+
nil
|
|
60
|
+
when String
|
|
61
|
+
parsed = begin
|
|
62
|
+
Date._parse(value)
|
|
63
|
+
rescue ArgumentError, TypeError
|
|
64
|
+
raise error, "#{field} is invalid"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if parsed[:offset].nil?
|
|
68
|
+
if parsed[:zone]
|
|
69
|
+
raise error, "#{field} has an offset outside the valid range: #{parsed[:zone].inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raise error, "#{field} must include an explicit UTC offset"
|
|
73
|
+
end
|
|
74
|
+
when Date
|
|
75
|
+
raise error, "#{field} must include a time of day and an explicit UTC offset"
|
|
76
|
+
else
|
|
77
|
+
unless value.respond_to?(:time_zone) && !value.time_zone.nil?
|
|
78
|
+
raise error, "#{field} must include an explicit UTC offset"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_class_method :check_offset!
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
module Internal
|
|
3
|
+
# Detects an open application database transaction without depending on Active
|
|
4
|
+
# Record.
|
|
5
|
+
#
|
|
6
|
+
# `ActiveRecord.after_all_transactions_commit` runs its block synchronously when no
|
|
7
|
+
# transaction is open and defers it to commit when one is, so calling it with a
|
|
8
|
+
# block that records whether it ran answers the question using only Active Record's
|
|
9
|
+
# public API -- no connection pool, no internal transaction manager, and nothing at
|
|
10
|
+
# all when Active Record is absent.
|
|
11
|
+
module Transaction
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def check!
|
|
15
|
+
return unless open?
|
|
16
|
+
|
|
17
|
+
raise TransactionalPublicationError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def open?
|
|
21
|
+
return false unless defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
|
|
22
|
+
|
|
23
|
+
deferred = true
|
|
24
|
+
::ActiveRecord.after_all_transactions_commit { deferred = false }
|
|
25
|
+
deferred
|
|
26
|
+
rescue StandardError
|
|
27
|
+
# Active Record is loaded but not usable -- no connection configured, for
|
|
28
|
+
# instance. Publication is not the place to turn that into a failure.
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
require "active_model/type"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module EventRail
|
|
7
|
+
module Internal
|
|
8
|
+
module Types
|
|
9
|
+
# Every attribute type owns the translation between its cast value and its
|
|
10
|
+
# written form, in both directions, under Active Model's own names. The written
|
|
11
|
+
# form is always a JSON primitive: Active Job does not recurse into a custom
|
|
12
|
+
# serializer's output, so anything else reaches the queue adapter raw.
|
|
13
|
+
#
|
|
14
|
+
# The walk is type-directed rather than shape-guessing. `deserialize` is the
|
|
15
|
+
# trusted wire-input direction the private Active Job serializer and external
|
|
16
|
+
# codecs read through; `cast` stays the strict door for local construction.
|
|
17
|
+
|
|
18
|
+
# Written forms for the scalar types EventRail supports. Each codec states the
|
|
19
|
+
# cast classes it accepts so a delegate that reports a familiar `type` while
|
|
20
|
+
# casting to something else fails during construction rather than writing a
|
|
21
|
+
# value its own reader cannot read back.
|
|
22
|
+
class ScalarCodec
|
|
23
|
+
attr_reader :classes
|
|
24
|
+
|
|
25
|
+
def initialize(classes:, write:, read:)
|
|
26
|
+
@classes = classes.freeze
|
|
27
|
+
@write = write
|
|
28
|
+
@read = read
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def accepts?(value)
|
|
33
|
+
classes.any? { |klass| value.instance_of?(klass) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(value)
|
|
37
|
+
@write.call(value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read(value)
|
|
41
|
+
@read.call(value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
SCALAR_CODECS = {
|
|
46
|
+
string: ScalarCodec.new(
|
|
47
|
+
classes: [ String ],
|
|
48
|
+
write: ->(value) { value },
|
|
49
|
+
read: ->(value) { value }
|
|
50
|
+
),
|
|
51
|
+
integer: ScalarCodec.new(
|
|
52
|
+
classes: [ Integer ],
|
|
53
|
+
write: ->(value) { value },
|
|
54
|
+
read: ->(value) { value }
|
|
55
|
+
),
|
|
56
|
+
float: ScalarCodec.new(
|
|
57
|
+
classes: [ Float ],
|
|
58
|
+
write: ->(value) { value },
|
|
59
|
+
read: ->(value) { value }
|
|
60
|
+
),
|
|
61
|
+
boolean: ScalarCodec.new(
|
|
62
|
+
classes: [ TrueClass, FalseClass ],
|
|
63
|
+
write: ->(value) { value },
|
|
64
|
+
read: ->(value) { value }
|
|
65
|
+
),
|
|
66
|
+
decimal: ScalarCodec.new(
|
|
67
|
+
classes: [ BigDecimal ],
|
|
68
|
+
write: ->(value) { value.to_s("F").freeze },
|
|
69
|
+
read: ->(value) { value }
|
|
70
|
+
),
|
|
71
|
+
date: ScalarCodec.new(
|
|
72
|
+
classes: [ Date ],
|
|
73
|
+
write: ->(value) { value.iso8601.freeze },
|
|
74
|
+
read: ->(value) { value }
|
|
75
|
+
),
|
|
76
|
+
datetime: ScalarCodec.new(
|
|
77
|
+
classes: [ Time ],
|
|
78
|
+
write: ->(value) { Timestamp.written(value) },
|
|
79
|
+
read: ->(value) { value }
|
|
80
|
+
)
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# Rails' :time is a time-of-day type: casting an instant through it discards
|
|
84
|
+
# the date. A durable fact cannot record that, and :datetime is the type that
|
|
85
|
+
# keeps the whole instant.
|
|
86
|
+
REDIRECTED_TYPES = { time: :datetime }.freeze
|
|
87
|
+
|
|
88
|
+
module Portable
|
|
89
|
+
def serialize(value)
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def deserialize(value)
|
|
94
|
+
cast(value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Raw < ActiveModel::Type::Value
|
|
99
|
+
include Portable
|
|
100
|
+
|
|
101
|
+
def cast(value)
|
|
102
|
+
PortableValue.raw(value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Active Model inherits form-oriented coercion that turns unusable input into a
|
|
107
|
+
# plausible value: "abc" becomes 0, "12abc" becomes 12, true becomes "t", and
|
|
108
|
+
# anything outside a known false list becomes true. A durable fact must not
|
|
109
|
+
# record a fabricated value that no validation can distinguish from a supplied
|
|
110
|
+
# one, so casting is gated on losslessness. The rule is about discarded
|
|
111
|
+
# information rather than trust, so it holds identically for local construction
|
|
112
|
+
# and for wire input: parsing "2026-09-01" into a date stays legal.
|
|
113
|
+
module Lossless
|
|
114
|
+
BOOLEAN_STRINGS = %w[true false t f 1 0 on off].freeze
|
|
115
|
+
BOOLEAN_INTEGERS = [ 0, 1 ].freeze
|
|
116
|
+
ISO_DATE = /\A-?\d{4,}-\d{2}-\d{2}\z/
|
|
117
|
+
|
|
118
|
+
module_function
|
|
119
|
+
|
|
120
|
+
def check!(value, type, path:)
|
|
121
|
+
case type
|
|
122
|
+
when :integer then check_integer!(value, path)
|
|
123
|
+
when :float then check_float!(value, path)
|
|
124
|
+
when :decimal then check_decimal!(value, path)
|
|
125
|
+
when :boolean then check_boolean!(value, path)
|
|
126
|
+
when :string then check_string!(value, path)
|
|
127
|
+
when :date then check_date!(value, path)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check_integer!(value, path)
|
|
132
|
+
case value
|
|
133
|
+
when Integer then nil
|
|
134
|
+
when String then reject!(path, value, "integer") if Integer(value, exception: false).nil?
|
|
135
|
+
when Numeric
|
|
136
|
+
reject!(path, value, "integer") unless value.finite? && value.to_i == value
|
|
137
|
+
else
|
|
138
|
+
reject!(path, value, "integer")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def check_float!(value, path)
|
|
143
|
+
case value
|
|
144
|
+
when Numeric then nil
|
|
145
|
+
when String then reject!(path, value, "float") if Float(value, exception: false).nil?
|
|
146
|
+
else reject!(path, value, "float")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_decimal!(value, path)
|
|
151
|
+
case value
|
|
152
|
+
when Numeric then nil
|
|
153
|
+
when String then reject!(path, value, "decimal") if BigDecimal(value, exception: false).nil?
|
|
154
|
+
else reject!(path, value, "decimal")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def check_boolean!(value, path)
|
|
159
|
+
case value
|
|
160
|
+
when true, false then nil
|
|
161
|
+
when String then reject!(path, value, "boolean") unless BOOLEAN_STRINGS.include?(value.downcase)
|
|
162
|
+
when Integer then reject!(path, value, "boolean") unless BOOLEAN_INTEGERS.include?(value)
|
|
163
|
+
else reject!(path, value, "boolean")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Rails renders any object into a string attribute, so a boolean becomes "t"
|
|
168
|
+
# and an integer becomes "5". Neither is recoverable, and neither is what the
|
|
169
|
+
# caller meant to record.
|
|
170
|
+
def check_string!(value, path)
|
|
171
|
+
reject!(path, value, "string") unless value.is_a?(String)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Casting an instant to a date discards its time of day, and which date it
|
|
175
|
+
# discards to depends on an offset the caller may not have stated. A date
|
|
176
|
+
# attribute therefore takes a date, spelled as a Date or as a complete ISO
|
|
177
|
+
# date, and refuses anything carrying a time.
|
|
178
|
+
def check_date!(value, path)
|
|
179
|
+
return if value.instance_of?(Date)
|
|
180
|
+
return if value.is_a?(String) && value.match?(ISO_DATE)
|
|
181
|
+
|
|
182
|
+
if value.is_a?(String) || value.is_a?(Time) || value.is_a?(DateTime)
|
|
183
|
+
raise CastingError,
|
|
184
|
+
"#{path} cannot represent #{value.inspect} as a date without discarding its time of day; " \
|
|
185
|
+
"supply a Date or a YYYY-MM-DD string"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
reject!(path, value, "date")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def reject!(path, value, label)
|
|
192
|
+
raise CastingError, "#{path} cannot represent #{value.inspect} as #{label} without discarding information"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
class Scalar < ActiveModel::Type::Value
|
|
197
|
+
attr_reader :delegate
|
|
198
|
+
|
|
199
|
+
def initialize(delegate)
|
|
200
|
+
@delegate = delegate
|
|
201
|
+
@codec = SCALAR_CODECS[delegate.type]
|
|
202
|
+
freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def cast(value)
|
|
206
|
+
return if value.nil?
|
|
207
|
+
|
|
208
|
+
PortableValue.reject_record!(value)
|
|
209
|
+
Lossless.check!(value, type, path: "value")
|
|
210
|
+
|
|
211
|
+
casted = if type == :datetime
|
|
212
|
+
Timestamp.normalize(value, field: "value", error: CastingError)
|
|
213
|
+
else
|
|
214
|
+
@delegate.cast(value)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if casted.nil?
|
|
218
|
+
raise CastingError, "value cannot represent #{value.inspect} as #{type} without discarding information"
|
|
219
|
+
end
|
|
220
|
+
return custom_value(casted) unless @codec
|
|
221
|
+
|
|
222
|
+
unless @codec.accepts?(casted)
|
|
223
|
+
raise CastingError, "value for #{type} cast to unsupported #{casted.class}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
PortableValue.typed(casted)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def serialize(value)
|
|
230
|
+
return if value.nil?
|
|
231
|
+
|
|
232
|
+
@codec ? @codec.write(value) : @delegate.serialize(value)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Every built-in written form is also a form `cast` accepts losslessly, so the
|
|
236
|
+
# strict door doubles as the trusted one and there is no second parser to keep
|
|
237
|
+
# in step with the writer. A custom type reads its own written form.
|
|
238
|
+
def deserialize(value)
|
|
239
|
+
return if value.nil?
|
|
240
|
+
|
|
241
|
+
return custom_value(@delegate.deserialize(value)) unless @codec
|
|
242
|
+
|
|
243
|
+
cast(@codec.read(value))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def type
|
|
247
|
+
@delegate.type
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
# A type that supplies its own written form may cast to its own value
|
|
252
|
+
# object. Only the written form has to be portable, so the cast value is
|
|
253
|
+
# frozen and otherwise left alone; deep immutability of a custom value
|
|
254
|
+
# object belongs to the type that defines it.
|
|
255
|
+
def custom_value(value)
|
|
256
|
+
return if value.nil?
|
|
257
|
+
|
|
258
|
+
PortableValue.reject_record!(value)
|
|
259
|
+
value.frozen? ? value : value.freeze
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
class NestedData < ActiveModel::Type::Value
|
|
264
|
+
attr_reader :data_class
|
|
265
|
+
|
|
266
|
+
def initialize(data_class)
|
|
267
|
+
@data_class = data_class
|
|
268
|
+
freeze
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Nested Ruby class names never cross a boundary: the record's own portable
|
|
272
|
+
# projection already is its written form.
|
|
273
|
+
def serialize(value)
|
|
274
|
+
value&.data
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Trusted, so a field the local class does not declare is preserved rather
|
|
278
|
+
# than rejected. Without this, adding an optional nested attribute without a
|
|
279
|
+
# version bump would break every worker that has not yet deployed it.
|
|
280
|
+
def deserialize(value)
|
|
281
|
+
return if value.nil?
|
|
282
|
+
|
|
283
|
+
unless value.is_a?(Hash)
|
|
284
|
+
raise CastingError, "nested #{data_class} must be reconstructed from a hash"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
data_class.send(:__event_rail_reconstruct__, value)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def cast(value)
|
|
291
|
+
return if value.nil?
|
|
292
|
+
return value if value.instance_of?(data_class)
|
|
293
|
+
|
|
294
|
+
unless value.is_a?(Hash)
|
|
295
|
+
raise CastingError, "nested #{data_class} must be constructed from a hash"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
data_class.new(value)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def type
|
|
302
|
+
:event_rail_data
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
class ArrayOf < ActiveModel::Type::Value
|
|
307
|
+
attr_reader :item_type
|
|
308
|
+
|
|
309
|
+
def initialize(item_type)
|
|
310
|
+
@item_type = item_type
|
|
311
|
+
freeze
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def serialize(value)
|
|
315
|
+
return if value.nil?
|
|
316
|
+
|
|
317
|
+
value.map { |item| item_type.serialize(item) }.freeze
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def deserialize(value)
|
|
321
|
+
return if value.nil?
|
|
322
|
+
|
|
323
|
+
each_item(value, "reconstructed") { |item| item_type.deserialize(item) }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def cast(value)
|
|
327
|
+
return if value.nil?
|
|
328
|
+
|
|
329
|
+
each_item(value, "constructed") { |item| item_type.cast(item) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def type
|
|
333
|
+
:event_rail_array
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
private
|
|
337
|
+
def each_item(value, verb)
|
|
338
|
+
unless value.is_a?(Array)
|
|
339
|
+
raise CastingError, "array attribute must be #{verb} from an array"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
value.each_with_index.map do |item, index|
|
|
343
|
+
yield item
|
|
344
|
+
rescue Error => error
|
|
345
|
+
contextual = if error.respond_to?(:validation_errors)
|
|
346
|
+
error.class.new(
|
|
347
|
+
"array item #{index}: #{error.message}",
|
|
348
|
+
validation_errors: error.validation_errors
|
|
349
|
+
)
|
|
350
|
+
else
|
|
351
|
+
error.class.new("array item #{index}: #{error.message}")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
raise contextual, cause: error
|
|
355
|
+
end.freeze
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
module_function
|
|
360
|
+
|
|
361
|
+
# Active Model already keeps a per-class memoized map of attribute name to
|
|
362
|
+
# type, invalidated on redeclaration, so the resolved type is the only thing
|
|
363
|
+
# worth returning: it is the whole definition.
|
|
364
|
+
def resolve(cast_type, array:, options:)
|
|
365
|
+
type = if cast_type.nil?
|
|
366
|
+
Raw.new
|
|
367
|
+
elsif cast_type.is_a?(Class) && cast_type < EventRail::Data
|
|
368
|
+
NestedData.new(cast_type)
|
|
369
|
+
else
|
|
370
|
+
Scalar.new(resolve_delegate(cast_type, options))
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
array ? ArrayOf.new(type) : type
|
|
374
|
+
rescue ArgumentError => error
|
|
375
|
+
raise DeclarationError, error.message
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def resolve_delegate(cast_type, options)
|
|
379
|
+
delegate = if cast_type.is_a?(ActiveModel::Type::Value)
|
|
380
|
+
cast_type
|
|
381
|
+
else
|
|
382
|
+
if (replacement = REDIRECTED_TYPES[cast_type])
|
|
383
|
+
raise DeclarationError,
|
|
384
|
+
"attribute type #{cast_type.inspect} discards the date portion of an instant; use #{replacement.inspect}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
ActiveModel::Type.lookup(cast_type, **options)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
verify_portable!(delegate)
|
|
391
|
+
delegate
|
|
392
|
+
end
|
|
393
|
+
private_class_method :resolve_delegate
|
|
394
|
+
|
|
395
|
+
# A type is either one EventRail supplies a written form for, or one that
|
|
396
|
+
# supplies its own and proves it here. Proving it at declaration is the point:
|
|
397
|
+
# the alternative is a job that enqueues fine in a unit test and is rejected by
|
|
398
|
+
# the production adapter.
|
|
399
|
+
def verify_portable!(delegate)
|
|
400
|
+
return if SCALAR_CODECS.key?(delegate.type) && !delegate.is_a?(EventRail::PortableType)
|
|
401
|
+
|
|
402
|
+
unless delegate.is_a?(EventRail::PortableType)
|
|
403
|
+
raise DeclarationError,
|
|
404
|
+
"attribute type #{delegate.class} reports #{delegate.type.inspect}, which EventRail has no written " \
|
|
405
|
+
"form for; supported types are #{SCALAR_CODECS.keys.sort.join(", ")}, or include " \
|
|
406
|
+
"EventRail::PortableType to supply your own"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
examples = delegate.portable_examples
|
|
410
|
+
unless examples.is_a?(Array) && !examples.empty?
|
|
411
|
+
raise DeclarationError, "#{delegate.class}#portable_examples must return at least one cast value"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
examples.each { |example| verify_example!(delegate, example) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def verify_example!(delegate, example)
|
|
418
|
+
written = delegate.serialize(example)
|
|
419
|
+
unless PortableValue.json_primitive?(written)
|
|
420
|
+
raise DeclarationError,
|
|
421
|
+
"#{delegate.class}#serialize returned #{written.class} for #{example.inspect}, which is not a JSON " \
|
|
422
|
+
"primitive, array, or string-keyed hash"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
decoded = JSON.parse(JSON.generate([ written ])).first
|
|
426
|
+
restored = delegate.deserialize(decoded)
|
|
427
|
+
unless restored == example
|
|
428
|
+
raise DeclarationError,
|
|
429
|
+
"#{delegate.class} does not round-trip #{example.inspect}: its written form reconstructs " \
|
|
430
|
+
"#{restored.inspect}"
|
|
431
|
+
end
|
|
432
|
+
rescue DeclarationError
|
|
433
|
+
raise
|
|
434
|
+
rescue StandardError => cause
|
|
435
|
+
raise DeclarationError,
|
|
436
|
+
"#{delegate.class} raised #{cause.class} writing or reading #{example.inspect}: #{cause.message}"
|
|
437
|
+
end
|
|
438
|
+
private_class_method :verify_example!
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|