serviced 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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ # Composes several steps into a single callable pipeline.
5
+ #
6
+ # A step is anything that responds to +call(context)+ and returns a
7
+ # {Serviced::Result}: usually a {Serviced::Service} subclass, but a lambda or
8
+ # any callable works too. Steps run in order, each receiving an immutable
9
+ # context hash. The first failing step halts the flow and its failure is
10
+ # returned. When every step succeeds, whatever hash each step returned as its
11
+ # success value is merged into the context, and the final context is returned
12
+ # as the success value.
13
+ #
14
+ # class RegisterPatient < Serviced::Flow
15
+ # transactional # wrap every step in one database transaction
16
+ #
17
+ # step CreatePatient # returns success(patient: patient)
18
+ # step CreateChart # reads :patient from the context
19
+ # step SendWelcome
20
+ # end
21
+ #
22
+ # result = RegisterPatient.call(name: "Ada", age: 36)
23
+ # result.value # => merged context hash of everything the steps produced
24
+ #
25
+ # A flow can also be built inline:
26
+ #
27
+ # RegisterPatient = Serviced::Flow.define(transaction: true) do
28
+ # step CreatePatient
29
+ # step CreateChart
30
+ # end
31
+ class Flow
32
+ include ResultHelpers
33
+
34
+ @steps = []
35
+ @transactional = false
36
+
37
+ class << self
38
+ # @return [Array<#call>] the steps registered on this flow
39
+ def steps
40
+ @steps ||= []
41
+ end
42
+
43
+ # Registers a step. Steps run in the order they are declared.
44
+ # @param callable [#call] a Service subclass, lambda, or any callable
45
+ # that returns a Serviced::Result
46
+ def step(callable)
47
+ steps << callable
48
+ end
49
+
50
+ # Marks the flow as transactional: all steps run inside a single
51
+ # transaction that rolls back if any step fails or raises.
52
+ def transactional
53
+ @transactional = true
54
+ end
55
+
56
+ # @return [Boolean] whether the flow runs inside a transaction
57
+ def transactional?
58
+ @transactional
59
+ end
60
+
61
+ # Builds an anonymous flow subclass from a block.
62
+ # @param transaction [Boolean] whether to wrap the steps in a transaction
63
+ # @return [Class] a new Serviced::Flow subclass
64
+ def define(transaction: false, &block)
65
+ Class.new(self) do
66
+ transactional if transaction
67
+ class_eval(&block) if block
68
+ end
69
+ end
70
+
71
+ # Runs the flow.
72
+ # @param context [Hash] initial context
73
+ # @return [Serviced::Result]
74
+ def call(context = {})
75
+ new(context).call
76
+ end
77
+
78
+ private
79
+
80
+ def inherited(subclass)
81
+ super
82
+ subclass.instance_variable_set(:@steps, steps.dup)
83
+ subclass.instance_variable_set(:@transactional, transactional?)
84
+ end
85
+ end
86
+
87
+ # @param context [Hash] initial context passed to the first step
88
+ def initialize(context = {})
89
+ @context = normalize(context).freeze
90
+ end
91
+
92
+ # @return [Serviced::Result]
93
+ def call
94
+ self.class.transactional? ? within_transaction { run_steps } : run_steps
95
+ end
96
+
97
+ private
98
+
99
+ def run_steps
100
+ context = @context
101
+ self.class.steps.each do |step|
102
+ result = invoke(step, context)
103
+ return result if result.failure?
104
+
105
+ context = merge(context, result.value)
106
+ end
107
+
108
+ success(context)
109
+ end
110
+
111
+ def invoke(step, context)
112
+ result = step.call(context)
113
+ return result if result.is_a?(Result)
114
+
115
+ raise ResultTypeError,
116
+ "Flow step #{step_name(step)} must return a Serviced::Result, got #{result.class}"
117
+ end
118
+
119
+ def within_transaction
120
+ handler = Serviced.configuration.transaction_handler
121
+ if handler.nil?
122
+ raise MissingTransactionHandler,
123
+ "#{self.class} is transactional but no transaction handler is configured. " \
124
+ "Load ActiveRecord or set Serviced.configuration.transaction_handler."
125
+ end
126
+
127
+ result = nil
128
+ begin
129
+ handler.call do
130
+ result = yield
131
+ raise Rollback if result.failure?
132
+ end
133
+ rescue Rollback
134
+ # Expected: a step failed, the transaction rolled back, and +result+
135
+ # already holds that failure.
136
+ end
137
+ result
138
+ end
139
+
140
+ def merge(context, value)
141
+ return context unless value.is_a?(Hash)
142
+
143
+ context.merge(normalize(value)).freeze
144
+ end
145
+
146
+ def normalize(hash)
147
+ (hash || {}).to_h.transform_keys(&:to_sym)
148
+ end
149
+
150
+ def step_name(step)
151
+ step.respond_to?(:name) ? step.name : step.class.name
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ # Base class for query objects: a named home for a complex read.
5
+ #
6
+ # A query has the same typed, immutable inputs as a {Serviced::Service} (see
7
+ # {Serviced::Typed}), but instead of a Result it returns whatever #call
8
+ # returns, usually an +ActiveRecord::Relation+. Returning a relation keeps the
9
+ # result composable: callers can still paginate, add includes, or chain more
10
+ # scopes. A service then consumes the query and wraps its result in a Result.
11
+ #
12
+ # class EnrolledPatientsQuery < Serviced::Query
13
+ # attribute :clinic
14
+ # attribute :program_external_id, :string
15
+ # attribute :sort_direction, :string, default: "asc"
16
+ #
17
+ # validates :sort_direction, inclusion: { in: %w[asc desc] }
18
+ #
19
+ # def call
20
+ # clinic.patients
21
+ # .where("enrolled_in(?)", program_external_id)
22
+ # .order("enrolled_at #{sort_direction}")
23
+ # end
24
+ # end
25
+ #
26
+ # relation = EnrolledPatientsQuery.call(clinic:, program_external_id: "cardio")
27
+ # relation.page(params[:page]) # still a relation
28
+ #
29
+ # Invalid inputs raise {Serviced::InvalidQuery} (a query has no failure
30
+ # channel, so bad input is treated as a programming error).
31
+ #
32
+ # The SQL helpers (#quote, #count_of, ...) require ActiveRecord at runtime.
33
+ class Query
34
+ include Typed
35
+
36
+ class << self
37
+ # Builds the query, validates it, and runs #call.
38
+ # @param attributes [Hash] input values; unknown keys are ignored
39
+ # @return [Object] whatever #call returns (typically an ActiveRecord::Relation)
40
+ # @raise [Serviced::InvalidQuery] if inputs fail validation
41
+ def call(attributes = {})
42
+ query = new(attributes)
43
+ raise InvalidQuery, query.errors if query.invalid?
44
+
45
+ query.call
46
+ end
47
+ end
48
+
49
+ # The query body. Subclasses must implement it and return a relation (to
50
+ # stay composable) or a materialized value.
51
+ # @return [ActiveRecord::Relation, Object]
52
+ def call
53
+ raise NotImplementedError, "#{self.class} must implement #call"
54
+ end
55
+
56
+ private
57
+
58
+ def connection
59
+ ActiveRecord::Base.connection
60
+ end
61
+
62
+ # Quotes a value for safe interpolation into raw SQL.
63
+ def quote(value)
64
+ connection.quote(value)
65
+ end
66
+
67
+ # Quotes a column or table name for safe interpolation into raw SQL.
68
+ def quote_column(name)
69
+ connection.quote_column_name(name)
70
+ end
71
+
72
+ # Builds a sanitized SQL fragment from a statement and bind values.
73
+ # sanitize("state = ? AND age > ?", "active", 18)
74
+ def sanitize(statement, *binds)
75
+ ActiveRecord::Base.sanitize_sql_array([statement, *binds])
76
+ end
77
+
78
+ # Counts the rows of a relation without loading them, wrapping it in a
79
+ # subquery. Replaces a hand-rolled "SELECT COUNT(*) FROM (...)" idiom.
80
+ # @param relation [ActiveRecord::Relation]
81
+ # @param cte [String] optional leading CTE (e.g. "WITH foo AS (...)")
82
+ # @return [Integer]
83
+ def count_of(relation, cte: "")
84
+ sql = "#{cte} SELECT COUNT(*) FROM (#{relation.to_sql}) serviced_count".strip
85
+ connection.select_value(sql).to_i
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ # Outcome of a service or flow. Abstract: instances are always either a
5
+ # {Serviced::Success} or a {Serviced::Failure}. Results are frozen and
6
+ # therefore immutable.
7
+ #
8
+ # Consume a result through predicates, callbacks, railway chaining, or
9
+ # pattern matching:
10
+ #
11
+ # result = CreatePatient.call(name: "Ada", age: 36)
12
+ #
13
+ # result.success? # => true / false
14
+ # result.value # success payload (nil on failure)
15
+ #
16
+ # result
17
+ # .on_success { |patient| render json: patient }
18
+ # .on_failure { |failure| render json: { error: failure.message } }
19
+ #
20
+ # case result
21
+ # in Serviced::Success(value:) then value
22
+ # in Serviced::Failure(reason:) then reason
23
+ # end
24
+ class Result
25
+ # @return [Boolean] whether the operation succeeded.
26
+ def success?
27
+ raise NotImplementedError, "#{self.class} must implement #success?"
28
+ end
29
+
30
+ # @return [Boolean] whether the operation failed.
31
+ def failure?
32
+ !success?
33
+ end
34
+
35
+ # The success payload. Always nil for a failure.
36
+ # @return [Object, nil]
37
+ def value
38
+ nil
39
+ end
40
+
41
+ # Runs the block with the success value when successful. Returns self so
42
+ # calls can be chained with {#on_failure}.
43
+ # @yieldparam value [Object] the success payload
44
+ # @return [Serviced::Result] self
45
+ def on_success
46
+ yield(value) if success? && block_given?
47
+ self
48
+ end
49
+
50
+ # Runs the block with the failure when failed. Returns self so calls can be
51
+ # chained with {#on_success}.
52
+ # @yieldparam failure [Serviced::Failure] the failure itself
53
+ # @return [Serviced::Result] self
54
+ def on_failure
55
+ yield(self) if failure? && block_given?
56
+ self
57
+ end
58
+
59
+ # Railway-oriented chaining: runs the block only on success and returns the
60
+ # Result it produces; short-circuits (returns self) on failure. The block
61
+ # must return a Serviced::Result.
62
+ # @yieldparam value [Object] the success payload
63
+ # @return [Serviced::Result]
64
+ def and_then
65
+ return self unless success?
66
+
67
+ result = yield(value)
68
+ unless result.is_a?(Result)
69
+ raise ResultTypeError, "#and_then block must return a Serviced::Result, got #{result.class}"
70
+ end
71
+
72
+ result
73
+ end
74
+
75
+ # Transforms a success value into a new Success; leaves a failure untouched.
76
+ # @yieldparam value [Object] the success payload
77
+ # @return [Serviced::Result]
78
+ def map
79
+ return self unless success?
80
+
81
+ Success.new(yield(value))
82
+ end
83
+ end
84
+
85
+ # A successful outcome carrying a payload.
86
+ class Success < Result
87
+ # @return [Object, nil] the success payload
88
+ attr_reader :value
89
+
90
+ # @param value [Object, nil] the payload the caller cares about
91
+ def initialize(value = nil)
92
+ super()
93
+ @value = value
94
+ freeze
95
+ end
96
+
97
+ def success?
98
+ true
99
+ end
100
+
101
+ # @return [Object, nil] the payload (never raises for a success)
102
+ def value!
103
+ value
104
+ end
105
+
106
+ def deconstruct
107
+ [value]
108
+ end
109
+
110
+ def deconstruct_keys(_keys)
111
+ { value: value }
112
+ end
113
+ end
114
+
115
+ # A failed outcome. Carries a machine-readable +reason+ for branching, an
116
+ # optional human-readable +message+, and an optional +error+ payload (for
117
+ # example an exception or an ActiveModel::Errors object).
118
+ class Failure < Result
119
+ # @return [Symbol] machine-readable reason, suitable for case/when branching
120
+ attr_reader :reason
121
+
122
+ # @return [String, nil] human-readable description
123
+ attr_reader :message
124
+
125
+ # @return [Object, nil] structured error payload (exception, error object, details)
126
+ attr_reader :error
127
+
128
+ # @param reason [Symbol] machine-readable reason (defaults to :error)
129
+ # @param message [String, nil] human-readable description
130
+ # @param error [Object, nil] structured error payload
131
+ def initialize(reason = :error, message = nil, error: nil)
132
+ super()
133
+ @reason = reason
134
+ @message = message
135
+ @error = error
136
+ freeze
137
+ end
138
+
139
+ def success?
140
+ false
141
+ end
142
+
143
+ # Always raises: a failure has no value to unwrap.
144
+ # @raise [Serviced::InvalidResultAccess]
145
+ def value!
146
+ raise InvalidResultAccess,
147
+ "Called #value! on a Failure (reason: #{reason.inspect}, message: #{message.inspect})"
148
+ end
149
+
150
+ def deconstruct
151
+ [reason, message]
152
+ end
153
+
154
+ def deconstruct_keys(_keys)
155
+ { reason: reason, message: message, error: error }
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ # Private helpers shared by services and flows for building results, so the
5
+ # construction of Success/Failure lives in one place.
6
+ module ResultHelpers
7
+ private
8
+
9
+ # Builds a success result. Pass a hash to contribute keys to a flow context,
10
+ # for example +success(patient: patient)+.
11
+ # @param value [Object, nil] the payload
12
+ # @return [Serviced::Success]
13
+ def success(value = nil)
14
+ Success.new(value)
15
+ end
16
+
17
+ # Builds a failure result.
18
+ # @param reason [Symbol] machine-readable reason for branching
19
+ # @param message [String, nil] human-readable description
20
+ # @param error [Object, nil] structured error payload
21
+ # @return [Serviced::Failure]
22
+ def failure(reason = :error, message = nil, error: nil)
23
+ Failure.new(reason, message, error: error)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ # Base class for service objects.
5
+ #
6
+ # A service declares its inputs as typed attributes (see {Serviced::Typed}),
7
+ # which are coerced on assignment and read-only afterwards. Inputs can be
8
+ # validated with the full ActiveModel validation DSL. Calling a service always
9
+ # returns a {Serviced::Result}.
10
+ #
11
+ # class CreatePatient < Serviced::Service
12
+ # attribute :name, :string
13
+ # attribute :age, :integer
14
+ # attribute :active, :boolean, default: true
15
+ # attribute :clinic # untyped: accepts any object
16
+ #
17
+ # validates :name, presence: true
18
+ # validates :age, numericality: { greater_than: 0 }
19
+ #
20
+ # def call
21
+ # patient = Patient.create!(name:, age:, active:)
22
+ # success(patient)
23
+ # rescue ActiveRecord::RecordInvalid => e
24
+ # failure(:not_created, e.message, error: e)
25
+ # end
26
+ # end
27
+ #
28
+ # result = CreatePatient.call(name: "Ada", age: 36)
29
+ # result.success? # => true
30
+ # result.value # => #<Patient ...>
31
+ #
32
+ # Invalid inputs short-circuit to +failure(:invalid)+ without running #call,
33
+ # exposing the ActiveModel::Errors object through +result.error+.
34
+ class Service
35
+ include Typed
36
+ include ResultHelpers
37
+
38
+ class << self
39
+ # Builds the service, validates it, and runs #call.
40
+ #
41
+ # Unknown keys are ignored so a service can be dropped into a {Flow}
42
+ # without matching the exact shape of the flow context.
43
+ #
44
+ # @param attributes [Hash] input values
45
+ # @return [Serviced::Result]
46
+ # @raise [Serviced::ResultTypeError] if #call returns a non-Result
47
+ def call(attributes = {})
48
+ service = new(attributes)
49
+ return service.__send__(:invalid_result) if service.invalid?
50
+
51
+ result = service.call
52
+ unless result.is_a?(Result)
53
+ raise ResultTypeError, "#{self}#call must return a Serviced::Result, got #{result.class}"
54
+ end
55
+
56
+ result
57
+ end
58
+ end
59
+
60
+ # The business logic. Subclasses must implement it and return a
61
+ # {Serviced::Result} (use the +success+ / +failure+ helpers).
62
+ # @return [Serviced::Result]
63
+ def call
64
+ raise NotImplementedError, "#{self.class} must implement #call"
65
+ end
66
+
67
+ private
68
+
69
+ def invalid_result
70
+ failure(:invalid, "Validation failed: #{Serviced.error_summary(errors)}", error: errors)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_model"
5
+
6
+ module Serviced
7
+ # Shared foundation for typed, immutable, validatable inputs. Included by both
8
+ # {Serviced::Service} and {Serviced::Query}, and usable on its own for any
9
+ # plain value object that wants the same contract.
10
+ #
11
+ # class DateRange
12
+ # include Serviced::Typed
13
+ # attribute :from, :date
14
+ # attribute :to, :date
15
+ # validates :from, :to, presence: true
16
+ # end
17
+ #
18
+ # Attributes are declared with the ActiveModel::Attributes DSL, so they are
19
+ # coerced to the declared type. Their writers are made private, so an instance
20
+ # cannot be rebound once built. Unknown keys are ignored, which lets these
21
+ # objects be fed from a wider hash (for example a {Serviced::Flow} context)
22
+ # without raising.
23
+ #
24
+ # Inputs are isolated by default: at construction each value is captured as an
25
+ # immutable snapshot (see {Serviced.snapshot}). Arrays, hashes, sets and
26
+ # strings are deep-copied and deep-frozen, so neither side can mutate the
27
+ # other. Objects with identity (ActiveRecord records and the like) are shared
28
+ # by reference and left alone, because a deep copy of a record is a different,
29
+ # non-persisted object. Pass +isolate: false+ to share a value by reference,
30
+ # for example a mutable accumulator you deliberately want the caller to see:
31
+ #
32
+ # attribute :filters # isolated snapshot (default)
33
+ # attribute :sink, isolate: false # shared by reference
34
+ module Typed
35
+ extend ActiveSupport::Concern
36
+
37
+ include ActiveModel::API
38
+ include ActiveModel::Attributes
39
+
40
+ # @param attributes [Hash] input values; unknown keys are ignored
41
+ def initialize(attributes = {})
42
+ super()
43
+ assign_typed_attributes(attributes)
44
+ capture_isolated_snapshots
45
+ end
46
+
47
+ private
48
+
49
+ def assign_typed_attributes(attributes)
50
+ return if attributes.nil?
51
+
52
+ permitted = attributes.to_h.transform_keys(&:to_sym)
53
+ self.class.attribute_names.each do |name|
54
+ key = name.to_sym
55
+ __send__(:"#{name}=", permitted[key]) if permitted.key?(key)
56
+ end
57
+ end
58
+
59
+ # Forces each isolated attribute to snapshot its value now, so isolation
60
+ # holds against mutations made between construction and first read.
61
+ def capture_isolated_snapshots
62
+ self.class.isolated_attribute_names.each { |name| __send__(name) }
63
+ end
64
+
65
+ class_methods do
66
+ # @return [Array<String>] names of attributes captured as isolated snapshots
67
+ def isolated_attribute_names
68
+ @isolated_attribute_names ||=
69
+ superclass.respond_to?(:isolated_attribute_names) ? superclass.isolated_attribute_names.dup : []
70
+ end
71
+
72
+ # Declares a typed, read-only attribute. Same arguments as
73
+ # ActiveModel::Attributes.attribute, plus +isolate:+.
74
+ #
75
+ # @param name [Symbol] attribute name
76
+ # @param type [Symbol, ActiveModel::Type::Value, nil] the cast type
77
+ # @param isolate [Boolean] capture an immutable snapshot of the value at
78
+ # construction (default); pass +false+ to share it by reference
79
+ # @param options [Hash] forwarded to ActiveModel (e.g. +default:+)
80
+ def attribute(name, type = nil, isolate: true, **options)
81
+ if type.nil?
82
+ super(name, **options)
83
+ else
84
+ super(name, type, **options)
85
+ end
86
+ private(:"#{name}=")
87
+ return unless isolate
88
+
89
+ isolated_attribute_names << name.to_s
90
+ define_isolated_reader(name)
91
+ end
92
+
93
+ private
94
+
95
+ def define_isolated_reader(name)
96
+ define_method(name) do
97
+ ivar = :"@__isolated_#{name}"
98
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
99
+
100
+ instance_variable_set(ivar, Serviced.snapshot(super()))
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Serviced
4
+ VERSION = "0.1.0"
5
+ end
data/lib/serviced.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "serviced/version"
6
+ require_relative "serviced/errors"
7
+ require_relative "serviced/result"
8
+ require_relative "serviced/result_helpers"
9
+ require_relative "serviced/configuration"
10
+ require_relative "serviced/typed"
11
+ require_relative "serviced/service"
12
+ require_relative "serviced/query"
13
+ require_relative "serviced/flow"
14
+
15
+ # Serviced provides small, explicit service objects: typed and immutable
16
+ # inputs, a mandatory Success/Failure return value, and composable flows with
17
+ # optional transactions.
18
+ #
19
+ # See {Serviced::Service} and {Serviced::Flow}.
20
+ module Serviced
21
+ class << self
22
+ # @return [Serviced::Configuration] the current configuration
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ # Yields the configuration for mutation.
28
+ # @yieldparam config [Serviced::Configuration]
29
+ def configure
30
+ yield(configuration)
31
+ end
32
+
33
+ # Resets configuration to defaults. Primarily useful in test suites.
34
+ # @return [Serviced::Configuration]
35
+ def reset_configuration!
36
+ @configuration = Configuration.new
37
+ end
38
+
39
+ # Renders an ActiveModel::Errors into a short human string. Falls back to
40
+ # the attribute names when full messages cannot be built: an anonymous
41
+ # class has no model_name, which ActiveModel needs to humanize messages.
42
+ # Building an error message must never itself raise.
43
+ # @param errors [ActiveModel::Errors]
44
+ # @return [String]
45
+ def error_summary(errors)
46
+ errors.full_messages.join(", ")
47
+ rescue StandardError
48
+ errors.attribute_names.join(", ")
49
+ end
50
+
51
+ # Returns an immutable snapshot of a value-like input. Arrays, hashes, sets
52
+ # and strings are deep-copied and frozen, so the result is isolated from the
53
+ # caller and cannot be mutated. Objects with identity (ActiveRecord records
54
+ # and other non-data objects) are returned by reference, unfrozen: a deep
55
+ # copy of a record is a different, non-persisted object, and freezing one in
56
+ # place would corrupt the caller's copy. Scalars are already immutable.
57
+ # @param value [Object]
58
+ # @return [Object] a frozen snapshot for value-like data, else the value itself
59
+ def snapshot(value)
60
+ case value
61
+ when Array then value.map { |element| snapshot(element) }.freeze
62
+ when Set then Set.new(value.map { |element| snapshot(element) }).freeze
63
+ when Hash then snapshot_hash(value)
64
+ when String then snapshot_string(value)
65
+ else value
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def snapshot_hash(hash)
72
+ hash.each_with_object({}) { |(key, value), copy| copy[snapshot(key)] = snapshot(value) }.freeze
73
+ end
74
+
75
+ def snapshot_string(string)
76
+ string.frozen? ? string : string.dup.freeze
77
+ end
78
+ end
79
+ end