pakyow-data 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE +4 -0
  4. data/README.md +29 -0
  5. data/lib/pakyow/data/adapters/abstract.rb +58 -0
  6. data/lib/pakyow/data/adapters/sql/commands.rb +58 -0
  7. data/lib/pakyow/data/adapters/sql/dataset_methods.rb +29 -0
  8. data/lib/pakyow/data/adapters/sql/differ.rb +76 -0
  9. data/lib/pakyow/data/adapters/sql/migrator/adapter_methods.rb +95 -0
  10. data/lib/pakyow/data/adapters/sql/migrator.rb +181 -0
  11. data/lib/pakyow/data/adapters/sql/migrators/automator.rb +49 -0
  12. data/lib/pakyow/data/adapters/sql/migrators/finalizer.rb +96 -0
  13. data/lib/pakyow/data/adapters/sql/runner.rb +49 -0
  14. data/lib/pakyow/data/adapters/sql/source_extension.rb +31 -0
  15. data/lib/pakyow/data/adapters/sql/types.rb +50 -0
  16. data/lib/pakyow/data/adapters/sql.rb +247 -0
  17. data/lib/pakyow/data/behavior/config.rb +28 -0
  18. data/lib/pakyow/data/behavior/lookup.rb +75 -0
  19. data/lib/pakyow/data/behavior/serialization.rb +40 -0
  20. data/lib/pakyow/data/connection.rb +103 -0
  21. data/lib/pakyow/data/container.rb +273 -0
  22. data/lib/pakyow/data/errors.rb +169 -0
  23. data/lib/pakyow/data/framework.rb +42 -0
  24. data/lib/pakyow/data/helpers.rb +11 -0
  25. data/lib/pakyow/data/lookup.rb +85 -0
  26. data/lib/pakyow/data/migrator.rb +182 -0
  27. data/lib/pakyow/data/object.rb +98 -0
  28. data/lib/pakyow/data/proxy.rb +262 -0
  29. data/lib/pakyow/data/result.rb +53 -0
  30. data/lib/pakyow/data/sources/abstract.rb +82 -0
  31. data/lib/pakyow/data/sources/ephemeral.rb +72 -0
  32. data/lib/pakyow/data/sources/relational/association.rb +43 -0
  33. data/lib/pakyow/data/sources/relational/associations/belongs_to.rb +47 -0
  34. data/lib/pakyow/data/sources/relational/associations/has_many.rb +54 -0
  35. data/lib/pakyow/data/sources/relational/associations/has_one.rb +54 -0
  36. data/lib/pakyow/data/sources/relational/associations/through.rb +67 -0
  37. data/lib/pakyow/data/sources/relational/command.rb +531 -0
  38. data/lib/pakyow/data/sources/relational/migrator.rb +101 -0
  39. data/lib/pakyow/data/sources/relational.rb +587 -0
  40. data/lib/pakyow/data/subscribers/adapters/memory.rb +153 -0
  41. data/lib/pakyow/data/subscribers/adapters/redis/pipeliner.rb +45 -0
  42. data/lib/pakyow/data/subscribers/adapters/redis/scripts/_shared.lua +73 -0
  43. data/lib/pakyow/data/subscribers/adapters/redis/scripts/expire.lua +16 -0
  44. data/lib/pakyow/data/subscribers/adapters/redis/scripts/persist.lua +15 -0
  45. data/lib/pakyow/data/subscribers/adapters/redis/scripts/register.lua +37 -0
  46. data/lib/pakyow/data/subscribers/adapters/redis.rb +209 -0
  47. data/lib/pakyow/data/subscribers.rb +148 -0
  48. data/lib/pakyow/data/tasks/bootstrap.rake +18 -0
  49. data/lib/pakyow/data/tasks/create.rake +22 -0
  50. data/lib/pakyow/data/tasks/drop.rake +32 -0
  51. data/lib/pakyow/data/tasks/finalize.rake +56 -0
  52. data/lib/pakyow/data/tasks/migrate.rake +24 -0
  53. data/lib/pakyow/data/tasks/reset.rake +18 -0
  54. data/lib/pakyow/data/types.rb +37 -0
  55. data/lib/pakyow/data.rb +27 -0
  56. data/lib/pakyow/environment/data/auto_migrate.rb +31 -0
  57. data/lib/pakyow/environment/data/config.rb +54 -0
  58. data/lib/pakyow/environment/data/connections.rb +76 -0
  59. data/lib/pakyow/environment/data/memory_db.rb +23 -0
  60. data/lib/pakyow/validations/unique.rb +26 -0
  61. metadata +186 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Data
5
+ module Adapters
6
+ class Sql
7
+ class Runner
8
+ def initialize(connection, migration_path)
9
+ @connection, @migration_path = connection, migration_path
10
+ end
11
+
12
+ def disconnect!
13
+ @connection.disconnect
14
+ end
15
+
16
+ def run!
17
+ Pakyow.module_eval do
18
+ unless singleton_class.instance_methods.include?(:migration)
19
+ def self.migration(&block)
20
+ Sequel.migration(&block)
21
+ end
22
+ end
23
+ end
24
+
25
+ # Allows migrations to be defined with the nice mapping, then executed with the Sequel type.
26
+ #
27
+ local_types = @connection.types
28
+ @connection.adapter.connection.define_singleton_method :type_literal do |column|
29
+ if column[:type].is_a?(Symbol)
30
+ begin
31
+ column[:type] = Data::Types.type_for(column[:type], local_types).meta[:database_type]
32
+ rescue Pakyow::UnknownType
33
+ end
34
+ end
35
+
36
+ super(column)
37
+ end
38
+
39
+ Sequel.extension :migration
40
+ Sequel::Migrator.run(
41
+ @connection.adapter.connection, @migration_path,
42
+ allow_missing_migration_files: true
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Data
5
+ module Adapters
6
+ class Sql
7
+ module SourceExtension
8
+ extend Support::Extension
9
+
10
+ private def build(string, *args)
11
+ Sequel.lit(string, *args)
12
+ end
13
+
14
+ apply_extension do
15
+ class_state :dataset_table, default: self.__object_name.name
16
+
17
+ class << self
18
+ def table(table_name)
19
+ @dataset_table = table_name
20
+ end
21
+
22
+ def default_primary_key_type
23
+ :bignum
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Data
5
+ module Adapters
6
+ class Sql
7
+ module Types
8
+ module Postgres
9
+ TYPES = {
10
+ bignum: Sql::TYPES[:bignum].meta(native_type: "bigint"),
11
+ decimal: Sql::TYPES[:decimal].meta(column_type: :decimal, native_type: ->(meta) { "numeric(#{meta[:size][0]},#{meta[:size][1]})" }),
12
+ integer: Sql::TYPES[:integer].meta(native_type: "integer"),
13
+ string: Sql::TYPES[:string].meta(native_type: "text"),
14
+ text: Sql::TYPES[:text].meta(column_type: :string),
15
+
16
+ json: Pakyow::Data::Types.Constructor(:json) { |value|
17
+ Sequel.pg_json(value)
18
+ }.meta(mapping: :json, database_type: :json)
19
+ }.freeze
20
+ end
21
+
22
+ module SQLite
23
+ TYPES = {
24
+ bignum: Sql::TYPES[:bignum].meta(native_type: "bigint"),
25
+ decimal: Sql::TYPES[:decimal].meta(column_type: :decimal, native_type: ->(meta) { "numeric(#{meta[:size][0]}, #{meta[:size][1]})" }),
26
+ integer: Sql::TYPES[:integer].meta(native_type: "integer"),
27
+ string: Sql::TYPES[:string].meta(native_type: "varchar(255)"),
28
+ text: Sql::TYPES[:text].meta(column_type: :string),
29
+
30
+ # Used indirectly for migrations to override the column type (since
31
+ # sqlite doesn't support bignum as a primary key).
32
+ #
33
+ pk_bignum: Sql::TYPES[:bignum].meta(column_type: :integer)
34
+ }.freeze
35
+ end
36
+
37
+ module MySQL
38
+ TYPES = {
39
+ bignum: Sql::TYPES[:bignum].meta(native_type: "bigint(20)"),
40
+ decimal: Sql::TYPES[:decimal].meta(column_type: :decimal, native_type: ->(meta) { "decimal(#{meta[:size][0]},#{meta[:size][1]})" }),
41
+ integer: Sql::TYPES[:integer].meta(native_type: "int(11)"),
42
+ string: Sql::TYPES[:string].meta(native_type: "varchar(255)"),
43
+ text: Sql::TYPES[:text].meta(column_type: :string)
44
+ }.freeze
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+
5
+ require "pakyow/support/extension"
6
+
7
+ require "pakyow/support/core_refinements/string/normalization"
8
+
9
+ require "pakyow/data/adapters/abstract"
10
+
11
+ module Pakyow
12
+ module Data
13
+ module Adapters
14
+ class Sql < Abstract
15
+ require "pakyow/data/adapters/sql/commands"
16
+ require "pakyow/data/adapters/sql/dataset_methods"
17
+ require "pakyow/data/adapters/sql/migrator"
18
+ require "pakyow/data/adapters/sql/runner"
19
+ require "pakyow/data/adapters/sql/source_extension"
20
+
21
+ TYPES = {
22
+ # overrides for default types
23
+ boolean: Data::Types::MAPPING[:boolean].meta(default: false, database_type: :boolean, column_type: :boolean),
24
+ date: Data::Types::MAPPING[:date].meta(database_type: :date),
25
+ datetime: Data::Types::MAPPING[:datetime].meta(database_type: DateTime),
26
+ decimal: Data::Types::MAPPING[:decimal].meta(database_type: BigDecimal, size: [10, 2]),
27
+ float: Data::Types::MAPPING[:float].meta(database_type: :float),
28
+ integer: Data::Types::MAPPING[:integer].meta(database_type: Integer),
29
+ string: Data::Types::MAPPING[:string].meta(database_type: String),
30
+ time: Data::Types::MAPPING[:time].meta(database_type: Time, column_type: :datetime),
31
+
32
+ # sql-specific types
33
+ file: Types.Constructor(Sequel::SQL::Blob).meta(mapping: :file, database_type: File, column_type: :blob),
34
+ text: Types::Coercible::String.meta(mapping: :text, database_type: :text, column_type: :text, native_type: "text"),
35
+ bignum: Types::Coercible::Integer.meta(mapping: :bignum, database_type: :Bignum)
36
+ }.freeze
37
+
38
+ require "pakyow/data/adapters/sql/types"
39
+
40
+ attr_reader :connection
41
+
42
+ DEFAULT_EXTENSIONS = %i(
43
+ connection_validator
44
+ ).freeze
45
+
46
+ DEFAULT_ADAPTER_EXTENSIONS = {
47
+ postgres: %i(
48
+ pg_json
49
+ ).freeze
50
+ }.freeze
51
+
52
+ def initialize(opts, logger: nil)
53
+ @opts, @logger = opts, logger
54
+ connect
55
+ end
56
+
57
+ def qualify_attribute(attribute, source)
58
+ Sequel.qualify(source.class.dataset_table, attribute)
59
+ end
60
+
61
+ def alias_attribute(attribute, as)
62
+ Sequel.as(attribute, as)
63
+ end
64
+
65
+ def dataset_for_source(source)
66
+ @connection[source.dataset_table]
67
+ end
68
+
69
+ def result_for_attribute_value(attribute, value, source)
70
+ source.where(attribute => value)
71
+ end
72
+
73
+ def restrict_to_source(restrict_to_source, source, *additional_fields)
74
+ source.select.qualify(
75
+ restrict_to_source.class.dataset_table
76
+ ).select_append(
77
+ *additional_fields
78
+ )
79
+ end
80
+
81
+ def restrict_to_attribute(attribute, source)
82
+ source.select(*attribute)
83
+ end
84
+
85
+ def merge_results(left_column_name, right_column_name, mergeable_source, merge_into_source)
86
+ merge_into_source.tap do
87
+ merge_into_source.__setobj__(
88
+ merge_into_source.join(
89
+ mergeable_source.class.dataset_table, left_column_name => right_column_name
90
+ )
91
+ )
92
+ end
93
+ end
94
+
95
+ def transaction(&block)
96
+ @connection.transaction do
97
+ begin
98
+ block.call
99
+ rescue Rollback
100
+ raise Sequel::Rollback
101
+ end
102
+ end
103
+ end
104
+
105
+ def connect
106
+ @connection = Sequel.connect(
107
+ adapter: @opts[:adapter],
108
+ database: @opts[:path],
109
+ host: @opts[:host],
110
+ port: @opts[:port],
111
+ user: @opts[:user],
112
+ password: @opts[:password],
113
+ logger: @logger
114
+ )
115
+
116
+ (DEFAULT_EXTENSIONS + DEFAULT_ADAPTER_EXTENSIONS[@opts[:adapter].to_s.to_sym].to_a).each do |extension|
117
+ @connection.extension extension
118
+ end
119
+
120
+ if @opts.include?(:timeout)
121
+ @connection.pool.connection_validation_timeout = @opts[:timeout].to_i
122
+ end
123
+ rescue Sequel::AdapterNotFound => error
124
+ raise MissingAdapter.build(error)
125
+ rescue Sequel::DatabaseConnectionError => error
126
+ raise ConnectionError.build(error)
127
+ end
128
+
129
+ def disconnect
130
+ if connected?
131
+ @connection.disconnect
132
+ end
133
+ end
134
+
135
+ def connected?
136
+ @connection.opts[:adapter] == "sqlite" || @connection.test_connection
137
+ rescue
138
+ false
139
+ end
140
+
141
+ def migratable?
142
+ true
143
+ end
144
+
145
+ def auto_migratable?
146
+ true
147
+ end
148
+
149
+ def finalized_attribute(attribute)
150
+ if attribute.meta[:primary_key] || attribute.meta[:foreign_key]
151
+ begin
152
+ finalized_attribute = Data::Types.type_for(:"pk_#{attribute.meta[:mapping]}", Sql.types_for_adapter(@connection.opts[:adapter].to_sym)).dup
153
+
154
+ if attribute.meta[:primary_key]
155
+ finalized_attribute = finalized_attribute.meta(primary_key: attribute.meta[:primary_key])
156
+ end
157
+
158
+ if attribute.meta[:foreign_key]
159
+ finalized_attribute = finalized_attribute.meta(foreign_key: attribute.meta[:foreign_key])
160
+ end
161
+ rescue Pakyow::UnknownType
162
+ finalized_attribute = attribute.dup
163
+ end
164
+ else
165
+ finalized_attribute = attribute.dup
166
+ end
167
+
168
+ finalized_meta = finalized_attribute.meta.dup
169
+
170
+ if finalized_meta.include?(:mapping)
171
+ finalized_meta[:migration_type] = finalized_meta[:mapping]
172
+ end
173
+
174
+ unless finalized_meta.include?(:migration_type)
175
+ finalized_meta[:migration_type] = migration_type_for_attribute(attribute)
176
+ end
177
+
178
+ unless finalized_meta.include?(:column_type)
179
+ finalized_meta[:column_type] = column_type_for_attribute(attribute)
180
+ end
181
+
182
+ unless finalized_meta.include?(:database_type)
183
+ finalized_meta[:database_type] = database_type_for_attribute(attribute)
184
+ end
185
+
186
+ finalized_meta.each do |key, value|
187
+ finalized_meta[key] = case value
188
+ when Proc
189
+ if value.arity == 1
190
+ value.call(finalized_meta)
191
+ else
192
+ value.call
193
+ end
194
+ else
195
+ value
196
+ end
197
+ end
198
+
199
+ finalized_attribute.meta(finalized_meta)
200
+ end
201
+
202
+ private
203
+
204
+ def migration_type_for_attribute(attribute)
205
+ attribute.meta[:database_type] || attribute.primitive
206
+ end
207
+
208
+ def column_type_for_attribute(attribute)
209
+ attribute.primitive.to_s.downcase.to_sym
210
+ end
211
+
212
+ def database_type_for_attribute(attribute)
213
+ attribute.primitive
214
+ end
215
+
216
+ class << self
217
+ CONNECTION_TYPES = {
218
+ postgres: "Types::Postgres",
219
+ sqlite: "Types::SQLite",
220
+ mysql2: "Types::MySQL"
221
+ }.freeze
222
+
223
+ def types_for_adapter(adapter)
224
+ TYPES.dup.merge(const_get(CONNECTION_TYPES[adapter.to_sym])::TYPES)
225
+ end
226
+
227
+ using Support::Refinements::String::Normalization
228
+
229
+ def build_opts(opts)
230
+ database = if opts[:adapter] == "sqlite"
231
+ if opts[:host]
232
+ File.join(opts[:host], opts[:path])
233
+ else
234
+ opts[:path]
235
+ end
236
+ else
237
+ String.normalize_path(opts[:path])[1..-1]
238
+ end
239
+
240
+ opts[:path] = database
241
+ opts
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Data
7
+ module Behavior
8
+ module Config
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ configurable :data do
13
+ configurable :subscriptions do
14
+ setting :adapter_settings, {}
15
+ setting :version
16
+
17
+ defaults :production do
18
+ setting :adapter_settings do
19
+ { key_prefix: [Pakyow.config.redis.key_prefix, config.name].join("/") }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ require "pakyow/data/container"
6
+ require "pakyow/data/lookup"
7
+ require "pakyow/data/subscribers"
8
+
9
+ module Pakyow
10
+ module Data
11
+ module Behavior
12
+ module Lookup
13
+ extend Support::Extension
14
+
15
+ # Data container object.
16
+ #
17
+ attr_reader :data
18
+
19
+ apply_extension do
20
+ after "boot", "initialize.data", priority: :high do
21
+ # Validate that each source connection exists.
22
+ #
23
+ state(:source).each do |source|
24
+ Pakyow.connection(source.adapter, source.connection)
25
+ end
26
+
27
+ subscribers = if is_a?(Plugin)
28
+ # Plugins should use the same subscribers object as their parent app.
29
+ #
30
+ parent.data.subscribers
31
+ else
32
+ Subscribers.new(
33
+ self,
34
+ Pakyow.config.data.subscriptions.adapter,
35
+ Pakyow.config.data.subscriptions.adapter_settings
36
+ )
37
+ end
38
+
39
+ containers = Pakyow.data_connections.values.each_with_object([]) { |connections, arr|
40
+ connections.values.each do |connection|
41
+ arr << Container.new(
42
+ connection: connection,
43
+ sources: state(:source).select { |source|
44
+ connection.name == source.connection && connection.type == source.adapter
45
+ },
46
+ objects: state(:object)
47
+ )
48
+ end
49
+ }
50
+
51
+ containers.each do |container|
52
+ container.finalize_associations!(containers - [container])
53
+ end
54
+
55
+ containers.each do |container|
56
+ container.finalize_sources!(containers - [container])
57
+ end
58
+
59
+ @data = Data::Lookup.new(
60
+ app: self,
61
+ containers: containers,
62
+ subscribers: subscribers
63
+ )
64
+ end
65
+
66
+ on "shutdown" do
67
+ if instance_variable_defined?(:@data)
68
+ @data.subscribers.shutdown
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+ require "pakyow/support/serializer"
5
+
6
+ module Pakyow
7
+ module Data
8
+ module Behavior
9
+ # Persists in-memory subscribers across restarts.
10
+ #
11
+ module Serialization
12
+ extend Support::Extension
13
+
14
+ apply_extension do
15
+ on "shutdown", priority: :high do
16
+ if Pakyow.config.data.subscriptions.adapter == :memory && data
17
+ subscriber_serializer.serialize
18
+ end
19
+ end
20
+
21
+ after "boot" do
22
+ if Pakyow.config.data.subscriptions.adapter == :memory && data
23
+ subscriber_serializer.deserialize
24
+ end
25
+ end
26
+ end
27
+
28
+ private def subscriber_serializer
29
+ Support::Serializer.new(
30
+ data.subscribers.adapter,
31
+ name: "#{config.name}-subscribers",
32
+ path: File.join(
33
+ Pakyow.config.root, "tmp", "state"
34
+ )
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "forwardable"
5
+
6
+ require "pakyow/support/class_state"
7
+ require "pakyow/support/deep_freeze"
8
+ require "pakyow/support/indifferentize"
9
+ require "pakyow/support/inflector"
10
+
11
+ module Pakyow
12
+ module Data
13
+ class Connection
14
+ extend Forwardable
15
+ def_delegators :@adapter, :dataset_for_source, :transaction
16
+
17
+ attr_reader :type, :name, :opts, :adapter, :failure
18
+
19
+ extend Support::DeepFreeze
20
+ unfreezable :logger, :adapter
21
+
22
+ def initialize(type:, name:, string: nil, opts: nil, logger: nil)
23
+ @type, @name, @logger, @failure = type, name, logger, nil
24
+
25
+ @opts = self.class.adapter(type).build_opts(
26
+ opts.is_a?(Hash) ? opts : self.class.parse_connection_string(string)
27
+ )
28
+
29
+ @adapter = self.class.adapter(@type).new(@opts, logger: @logger)
30
+ rescue ConnectionError, MissingAdapter => error
31
+ error.context = self
32
+ @failure = error
33
+ end
34
+
35
+ def connected?
36
+ !failed? && @adapter.connected?
37
+ end
38
+
39
+ def failed?
40
+ !@failure.nil?
41
+ end
42
+
43
+ def auto_migrate?
44
+ migratable? && @adapter.auto_migratable?
45
+ end
46
+
47
+ def migratable?
48
+ connected? && @adapter.migratable?
49
+ end
50
+
51
+ def connect
52
+ @adapter.connect
53
+ end
54
+
55
+ def disconnect
56
+ @adapter.disconnect
57
+ end
58
+
59
+ def types
60
+ if @adapter.class.const_defined?("TYPES")
61
+ @adapter.class.types_for_adapter(adapter.connection.opts[:adapter])
62
+ else
63
+ {}
64
+ end
65
+ end
66
+
67
+ extend Support::ClassState
68
+ class_state :adapter_types, default: []
69
+
70
+ using Support::Indifferentize
71
+
72
+ class << self
73
+ def parse_connection_string(connection_string)
74
+ uri = URI(connection_string)
75
+
76
+ {
77
+ adapter: uri.scheme,
78
+ path: uri.path,
79
+ host: uri.host,
80
+ port: uri.port,
81
+ user: uri.user,
82
+ password: uri.password
83
+ }.merge(
84
+ CGI::parse(uri.query.to_s).transform_values(&:first).indifferentize
85
+ )
86
+ end
87
+
88
+ def register_adapter(type)
89
+ (@adapter_types << type).uniq!
90
+ end
91
+
92
+ def adapter(type)
93
+ if @adapter_types.include?(type.to_sym)
94
+ require "pakyow/data/adapters/#{type}"
95
+ Adapters.const_get(Support.inflector.camelize(type))
96
+ else
97
+ raise UnknownAdapter.new_with_message(type: type)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end