terrestrial 0.1.0 → 0.1.1

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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -9
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +28 -0
  6. data/Gemfile.lock +73 -0
  7. data/LICENSE.txt +22 -0
  8. data/MissingFeatures.md +64 -0
  9. data/README.md +161 -16
  10. data/Rakefile +30 -0
  11. data/TODO.md +41 -0
  12. data/features/env.rb +60 -0
  13. data/features/example.feature +120 -0
  14. data/features/step_definitions/example_steps.rb +46 -0
  15. data/lib/terrestrial/abstract_record.rb +99 -0
  16. data/lib/terrestrial/association_loaders.rb +52 -0
  17. data/lib/terrestrial/collection_mutability_proxy.rb +81 -0
  18. data/lib/terrestrial/configurations/conventional_association_configuration.rb +186 -0
  19. data/lib/terrestrial/configurations/conventional_configuration.rb +302 -0
  20. data/lib/terrestrial/dataset.rb +49 -0
  21. data/lib/terrestrial/deleted_record.rb +20 -0
  22. data/lib/terrestrial/dirty_map.rb +42 -0
  23. data/lib/terrestrial/graph_loader.rb +63 -0
  24. data/lib/terrestrial/graph_serializer.rb +91 -0
  25. data/lib/terrestrial/identity_map.rb +22 -0
  26. data/lib/terrestrial/lazy_collection.rb +74 -0
  27. data/lib/terrestrial/lazy_object_proxy.rb +55 -0
  28. data/lib/terrestrial/many_to_many_association.rb +138 -0
  29. data/lib/terrestrial/many_to_one_association.rb +66 -0
  30. data/lib/terrestrial/mapper_facade.rb +137 -0
  31. data/lib/terrestrial/one_to_many_association.rb +66 -0
  32. data/lib/terrestrial/public_conveniencies.rb +139 -0
  33. data/lib/terrestrial/query_order.rb +32 -0
  34. data/lib/terrestrial/relation_mapping.rb +50 -0
  35. data/lib/terrestrial/serializer.rb +18 -0
  36. data/lib/terrestrial/short_inspection_string.rb +18 -0
  37. data/lib/terrestrial/struct_factory.rb +17 -0
  38. data/lib/terrestrial/subset_queries_proxy.rb +11 -0
  39. data/lib/terrestrial/upserted_record.rb +15 -0
  40. data/lib/terrestrial/version.rb +1 -1
  41. data/lib/terrestrial.rb +5 -2
  42. data/sequel_mapper.gemspec +31 -0
  43. data/spec/config_override_spec.rb +193 -0
  44. data/spec/custom_serializers_spec.rb +49 -0
  45. data/spec/deletion_spec.rb +101 -0
  46. data/spec/graph_persistence_spec.rb +313 -0
  47. data/spec/graph_traversal_spec.rb +121 -0
  48. data/spec/new_graph_persistence_spec.rb +71 -0
  49. data/spec/object_identity_spec.rb +70 -0
  50. data/spec/ordered_association_spec.rb +51 -0
  51. data/spec/persistence_efficiency_spec.rb +224 -0
  52. data/spec/predefined_queries_spec.rb +62 -0
  53. data/spec/proxying_spec.rb +88 -0
  54. data/spec/querying_spec.rb +48 -0
  55. data/spec/readme_examples_spec.rb +35 -0
  56. data/spec/sequel_mapper/abstract_record_spec.rb +244 -0
  57. data/spec/sequel_mapper/collection_mutability_proxy_spec.rb +135 -0
  58. data/spec/sequel_mapper/deleted_record_spec.rb +59 -0
  59. data/spec/sequel_mapper/dirty_map_spec.rb +214 -0
  60. data/spec/sequel_mapper/lazy_collection_spec.rb +119 -0
  61. data/spec/sequel_mapper/lazy_object_proxy_spec.rb +140 -0
  62. data/spec/sequel_mapper/public_conveniencies_spec.rb +58 -0
  63. data/spec/sequel_mapper/upserted_record_spec.rb +59 -0
  64. data/spec/spec_helper.rb +36 -0
  65. data/spec/support/blog_schema.rb +38 -0
  66. data/spec/support/have_persisted_matcher.rb +19 -0
  67. data/spec/support/mapper_setup.rb +221 -0
  68. data/spec/support/mock_sequel.rb +193 -0
  69. data/spec/support/object_graph_setup.rb +139 -0
  70. data/spec/support/seed_data_setup.rb +165 -0
  71. data/spec/support/sequel_persistence_setup.rb +19 -0
  72. data/spec/support/sequel_test_support.rb +166 -0
  73. metadata +207 -13
  74. data/.travis.yml +0 -4
  75. data/bin/console +0 -14
  76. data/bin/setup +0 -7
  77. data/terrestrial.gemspec +0 -23
@@ -0,0 +1,302 @@
1
+ require "terrestrial/configurations/conventional_association_configuration"
2
+ require "terrestrial/relation_mapping"
3
+ require "terrestrial/subset_queries_proxy"
4
+ require "terrestrial/struct_factory"
5
+
6
+ module Terrestrial
7
+ module Configurations
8
+ require "active_support/inflector"
9
+ class Inflector
10
+ include ActiveSupport::Inflector
11
+ end
12
+
13
+ INFLECTOR = Inflector.new
14
+
15
+ require "fetchable"
16
+ class ConventionalConfiguration
17
+ include Fetchable
18
+
19
+ def initialize(datastore)
20
+ @datastore = datastore
21
+ @overrides = {}
22
+ @subset_queries = {}
23
+ @associations_by_mapping = {}
24
+ end
25
+
26
+ attr_reader :datastore, :mappings
27
+ private :datastore, :mappings
28
+
29
+ def [](mapping_name)
30
+ mappings[mapping_name]
31
+ end
32
+
33
+ include Enumerable
34
+ def each(&block)
35
+ mappings.each(&block)
36
+ end
37
+
38
+ def setup_mapping(mapping_name, &block)
39
+ @associations_by_mapping[mapping_name] ||= []
40
+
41
+ block.call(
42
+ RelationConfigOptionsProxy.new(
43
+ method(:add_override).to_proc.curry.call(mapping_name),
44
+ method(:add_subset).to_proc.curry.call(mapping_name),
45
+ @associations_by_mapping.fetch(mapping_name),
46
+ )
47
+ ) if block
48
+
49
+ # TODO: more madness in this silly config this, kill it with fire.
50
+ explicit_settings = @overrides[mapping_name] ||= {}
51
+ explicit_settings[:factory] ||= raise_if_not_found_factory(mapping_name)
52
+
53
+ self
54
+ end
55
+
56
+ private
57
+
58
+ class RelationConfigOptionsProxy
59
+ def initialize(config_override, subset_adder, association_register)
60
+ @config_override = config_override
61
+ @subset_adder = subset_adder
62
+ @association_register = association_register
63
+ end
64
+
65
+ def relation_name(name)
66
+ @config_override.call(relation_name: name)
67
+ end
68
+ alias_method :table_name, :relation_name
69
+
70
+ def subset(subset_name, &block)
71
+ @subset_adder.call(subset_name, block)
72
+ end
73
+
74
+ def has_many(*args)
75
+ @association_register.push([:has_many, args])
76
+ end
77
+
78
+ def has_many_through(*args)
79
+ @association_register.push([:has_many_through, args])
80
+ end
81
+
82
+ def belongs_to(*args)
83
+ @association_register.push([:belongs_to, args])
84
+ end
85
+
86
+ def fields(field_names)
87
+ @config_override.call(fields: field_names)
88
+ end
89
+
90
+ def primary_key(field_names)
91
+ @config_override.call(primary_key: field_names)
92
+ end
93
+
94
+ def factory(callable)
95
+ @config_override.call(factory: callable)
96
+ end
97
+
98
+ def class(entity_class)
99
+ @config_override.call('class': entity_class)
100
+ end
101
+
102
+ def class_name(class_name)
103
+ @config_override.call(class_name: class_name)
104
+ end
105
+
106
+ def serializer(serializer_func)
107
+ @config_override.call(serializer: serializer_func)
108
+ end
109
+ end
110
+
111
+ def mappings
112
+ @mappings ||= generate_mappings
113
+ end
114
+
115
+ def add_override(mapping_name, attrs)
116
+ overrides = @overrides.fetch(mapping_name, {}).merge(attrs)
117
+
118
+ @overrides.store(mapping_name, overrides)
119
+ end
120
+
121
+ def add_subset(mapping_name, subset_name, block)
122
+ @subset_queries.store(
123
+ mapping_name,
124
+ @subset_queries.fetch(mapping_name, {}).merge(
125
+ subset_name => block,
126
+ )
127
+ )
128
+ end
129
+
130
+ def association_configurator(mappings, mapping_name)
131
+ ConventionalAssociationConfiguration.new(
132
+ mapping_name,
133
+ mappings,
134
+ datastore,
135
+ )
136
+ end
137
+
138
+ def generate_mappings
139
+ custom_mappings = @overrides.map { |mapping_name, overrides|
140
+ [mapping_name, {relation_name: mapping_name}.merge(consolidate_overrides(overrides))]
141
+ }
142
+
143
+ table_mappings = (tables - @overrides.keys).map { |table_name|
144
+ [table_name, overrides_for_table(table_name)]
145
+ }
146
+
147
+ Hash[
148
+ (table_mappings + custom_mappings).map { |(mapping_name, overrides)|
149
+ table_name = overrides.fetch(:relation_name) { raise no_table_error(mapping_name) }
150
+
151
+ [
152
+ mapping_name,
153
+ mapping(
154
+ **default_mapping_args(table_name, mapping_name).merge(overrides)
155
+ ),
156
+ ]
157
+ }
158
+ ]
159
+ .tap { |mappings|
160
+ generate_associations_config(mappings)
161
+ }
162
+ end
163
+
164
+ def generate_associations_config(mappings)
165
+ # TODO: the ConventionalAssociationConfiguration takes all the mappings
166
+ # as a dependency and then sends mutating messages to them.
167
+ # This mutation based approach was originally a spike but now just
168
+ # seems totally bananas!
169
+ @associations_by_mapping.each do |mapping_name, associations|
170
+ associations.each do |(assoc_type, assoc_args)|
171
+ association_configurator(mappings, mapping_name)
172
+ .public_send(assoc_type, *assoc_args)
173
+ end
174
+ end
175
+ end
176
+
177
+ def default_mapping_args(table_name, mapping_name)
178
+ {
179
+ name: mapping_name,
180
+ relation_name: table_name,
181
+ fields: get_fields(table_name),
182
+ primary_key: get_primary_key(table_name),
183
+ factory: ok_if_it_doesnt_exist_factory(mapping_name),
184
+ serializer: hash_coercion_serializer,
185
+ associations: {},
186
+ subsets: subset_queries_proxy(@subset_queries.fetch(mapping_name, {})),
187
+ }
188
+ end
189
+
190
+ def overrides_for_table(table_name)
191
+ overrides = @overrides.values.detect { |config|
192
+ table_name == config.fetch(:relation_name, nil)
193
+ } || {}
194
+
195
+ { relation_name: table_name }.merge(
196
+ consolidate_overrides(overrides)
197
+ )
198
+ end
199
+
200
+ def consolidate_overrides(opts)
201
+ new_opts = opts.dup
202
+
203
+ if new_opts.has_key?(:class_name)
204
+ new_opts.merge!(factory: string_to_factory(new_opts.fetch(:class_name)))
205
+ new_opts.delete(:class_name)
206
+ end
207
+
208
+ if new_opts.has_key?(:class)
209
+ new_opts.merge!(factory: class_to_factory(new_opts.fetch(:class)))
210
+ new_opts.delete(:class)
211
+ end
212
+
213
+ new_opts
214
+ end
215
+
216
+ def get_fields(table_name)
217
+ datastore[table_name].columns
218
+ end
219
+
220
+ def get_primary_key(table_name)
221
+ datastore.schema(table_name)
222
+ .select { |field_name, properties|
223
+ properties.fetch(:primary_key)
224
+ }
225
+ .map { |field_name, _| field_name }
226
+ end
227
+
228
+ def tables
229
+ (datastore.tables - [:schema_migrations])
230
+ end
231
+
232
+ def hash_coercion_serializer
233
+ ->(o) { o.to_h }
234
+ end
235
+
236
+ def subset_queries_proxy(subset_map)
237
+ SubsetQueriesProxy.new(subset_map)
238
+ end
239
+
240
+ def mapping(name:, relation_name:, primary_key:, factory:, serializer:, fields:, associations:, subsets:)
241
+ RelationMapping.new(
242
+ name: name,
243
+ namespace: relation_name,
244
+ primary_key: primary_key,
245
+ factory: factory,
246
+ serializer: serializer,
247
+ fields: fields,
248
+ associations: associations,
249
+ subsets: subsets,
250
+ )
251
+ end
252
+
253
+ FactoryNotFoundError = Class.new(StandardError) do
254
+ def initialize(specified)
255
+ @specified = specified
256
+ end
257
+
258
+ def message
259
+ "Could not find factory for #{@specified}"
260
+ end
261
+ end
262
+
263
+ TableNameNotSpecifiedError = Class.new(StandardError) do
264
+ def initialize(mapping_name)
265
+ @message = "Error defining custom mapping `#{mapping_name}`." +
266
+ " You must provide the `table_name` configuration option."
267
+ end
268
+ end
269
+
270
+ def raise_if_not_found_factory(name)
271
+ ->(attrs) {
272
+ class_to_factory(string_to_class(name)).call(attrs)
273
+ }
274
+ end
275
+
276
+ def ok_if_it_doesnt_exist_factory(name)
277
+ ->(attrs) {
278
+ factory = class_to_factory(string_to_class(name)) rescue nil
279
+ factory && factory.call(attrs)
280
+ }
281
+ end
282
+
283
+ def class_to_factory(klass)
284
+ if klass.ancestors.include?(Struct)
285
+ StructFactory.new(klass)
286
+ else
287
+ klass.method(:new)
288
+ end
289
+ end
290
+
291
+ def string_to_class(string)
292
+ klass_name = INFLECTOR.classify(string)
293
+
294
+ Object.const_get(klass_name)
295
+ end
296
+
297
+ def no_table_error(table_name)
298
+ TableNameNotSpecifiedError.new(table_name)
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,49 @@
1
+ module Terrestrial
2
+ class Dataset
3
+ include Enumerable
4
+
5
+ def initialize(records)
6
+ @records = records
7
+ end
8
+
9
+ attr_reader :records
10
+ private :records
11
+
12
+ def each(&block)
13
+ records.each(&block)
14
+ self
15
+ end
16
+
17
+ def where(criteria)
18
+ new(
19
+ records.find_all { |row|
20
+ criteria.all? { |k, v|
21
+ if v.respond_to?(:include?)
22
+ test_inclusion_in_value(row, k, v)
23
+ else
24
+ test_equality(row, k, v)
25
+ end
26
+ }
27
+ }
28
+ )
29
+ end
30
+
31
+ def select(field)
32
+ map { |data| data.fetch(field) }
33
+ end
34
+
35
+ private
36
+
37
+ def new(records)
38
+ self.class.new(records)
39
+ end
40
+
41
+ def test_inclusion_in_value(row, field, values)
42
+ values.include?(row.fetch(field))
43
+ end
44
+
45
+ def test_equality(row, field, value)
46
+ value == row.fetch(field)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ require "terrestrial/abstract_record"
2
+
3
+ module Terrestrial
4
+ class DeletedRecord < AbstractRecord
5
+ def if_delete(&block)
6
+ block.call(self)
7
+ self
8
+ end
9
+
10
+ def subset?(_other)
11
+ false
12
+ end
13
+
14
+ protected
15
+
16
+ def operation
17
+ :delete
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ module Terrestrial
2
+ class DirtyMap
3
+ def initialize(storage)
4
+ @storage = storage
5
+ end
6
+
7
+ attr_reader :storage
8
+ private :storage
9
+
10
+ def load(record)
11
+ storage.store(hash_key(record), deep_clone(record))
12
+ record
13
+ end
14
+
15
+ def dirty?(record)
16
+ record_as_loaded = storage.fetch(hash_key(record), NotFound)
17
+ return true if record_as_loaded == NotFound
18
+
19
+ !record.subset?(record_as_loaded)
20
+ end
21
+
22
+ def reject_unchanged_fields(record)
23
+ record_as_loaded = storage.fetch(hash_key(record), {})
24
+
25
+ record.reject { |field, value|
26
+ value == record_as_loaded.fetch(field, NotFound)
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ NotFound = Module.new
33
+
34
+ def hash_key(record)
35
+ deep_clone([record.namespace, record.identity])
36
+ end
37
+
38
+ def deep_clone(record)
39
+ Marshal.load(Marshal.dump(record))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ module Terrestrial
2
+ class GraphLoader
3
+ def initialize(datasets:, mappings:, object_load_pipeline:)
4
+ @datasets = datasets
5
+ @mappings = mappings
6
+ @object_load_pipeline = object_load_pipeline
7
+ end
8
+
9
+ attr_reader :datasets, :mappings, :object_load_pipeline
10
+
11
+ def call(mapping_name, record, eager_data = {})
12
+ mapping = mappings.fetch(mapping_name)
13
+
14
+ load_record(mapping, record, eager_data)
15
+ end
16
+
17
+ private
18
+
19
+ def load_record(mapping, record, eager_data)
20
+ associations = load_associations(mapping, record, eager_data)
21
+
22
+ object_load_pipeline.call(mapping, record, Hash[associations])
23
+ end
24
+
25
+ def load_associations(mapping, record, eager_data)
26
+ mapping.associations.map { |name, association|
27
+ assoc_eager_data = eager_data.fetch(name, {})
28
+
29
+ data_superset = assoc_eager_data.fetch(:superset) {
30
+ load_from_datasets(association)
31
+ }
32
+
33
+ [
34
+ name,
35
+ association.build_proxy(
36
+ record: record,
37
+ data_superset: data_superset,
38
+ loader: ->(associated_record, join_records = []) {
39
+ join_records.map { |jr|
40
+ join_mapping = mappings.fetch(association.join_mapping_name)
41
+ object_load_pipeline.call(join_mapping, jr)
42
+ }
43
+
44
+ call(
45
+ association.mapping_name,
46
+ associated_record,
47
+ assoc_eager_data.fetch(:associations, {})
48
+ )
49
+ },
50
+ )
51
+ ]
52
+ }
53
+ end
54
+
55
+ def load_from_datasets(association)
56
+ association
57
+ .mapping_names
58
+ .map { |name| mappings.fetch(name) }
59
+ .map(&:namespace)
60
+ .map { |ns| datasets[ns] }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,91 @@
1
+ require "terrestrial/upserted_record"
2
+ require "terrestrial/deleted_record"
3
+
4
+ module Terrestrial
5
+ class GraphSerializer
6
+ def initialize(mappings:)
7
+ @mappings = mappings
8
+ @serialization_map = {}
9
+ end
10
+
11
+ attr_reader :mappings, :serialization_map
12
+ private :mappings, :serialization_map
13
+
14
+ def call(mapping_name, object, depth = 0, parent_foreign_keys = {})
15
+ if serialization_map.include?(object)
16
+ return [serialization_map.fetch(object)]
17
+ end
18
+
19
+ mapping = mappings.fetch(mapping_name)
20
+
21
+ current_record, association_fields = mapping.serialize(
22
+ object,
23
+ depth,
24
+ parent_foreign_keys,
25
+ )
26
+
27
+ serialization_map.store(object, current_record)
28
+
29
+ (
30
+ [current_record] + associated_records(mapping, current_record, association_fields, depth)
31
+ ).flatten(1)
32
+ end
33
+
34
+ private
35
+
36
+ def associated_records(mapping, current_record, association_fields, depth)
37
+ mapping.associations
38
+ .map { |name, association|
39
+ [association_fields.fetch(name), association]
40
+ }
41
+ .map { |collection, association|
42
+ [nodes(collection), deleted_nodes(collection), association]
43
+ }
44
+ .map { |nodes, deleted_nodes, association|
45
+ association.dump(current_record, nodes, depth) { |assoc_mapping_name, assoc_object, foreign_key, assoc_depth|
46
+ call(assoc_mapping_name, assoc_object, assoc_depth, foreign_key).tap { |associated_record, *_join_records|
47
+ # TODO: remove this mutation
48
+ current_record.merge!(association.extract_foreign_key(associated_record))
49
+ }
50
+ } +
51
+ association.delete(current_record, deleted_nodes, depth) { |assoc_mapping_name, assoc_object, foreign_key, assoc_depth|
52
+ delete(assoc_mapping_name, assoc_object, assoc_depth, foreign_key)
53
+ }
54
+ }
55
+ end
56
+
57
+ def delete(mapping_name, object, depth, _foreign_key)
58
+ mapping = mappings.fetch(mapping_name)
59
+ serialized_record = mapping.serializer.call(object)
60
+
61
+ [
62
+ DeletedRecord.new(
63
+ mapping.namespace,
64
+ mapping.primary_key,
65
+ serialized_record,
66
+ depth,
67
+ )
68
+ ]
69
+ end
70
+
71
+ def nodes(collection)
72
+ if collection.respond_to?(:each_loaded)
73
+ collection.each_loaded
74
+ elsif collection.is_a?(Struct)
75
+ [collection]
76
+ elsif collection.respond_to?(:each)
77
+ collection.each
78
+ else
79
+ [collection]
80
+ end
81
+ end
82
+
83
+ def deleted_nodes(collection)
84
+ if collection.respond_to?(:each_deleted)
85
+ collection.each_deleted
86
+ else
87
+ []
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,22 @@
1
+ module Terrestrial
2
+ class IdentityMap
3
+ def initialize(storage)
4
+ @storage = storage
5
+ end
6
+
7
+ attr_reader :storage
8
+ private :storage
9
+
10
+ def call(mapping, record, object)
11
+ storage.fetch(hash_key(mapping, record)) {
12
+ storage.store(hash_key(mapping, record), object)
13
+ }
14
+ end
15
+
16
+ private
17
+
18
+ def hash_key(mapping, record)
19
+ [mapping.name, record.namespace, record.identity]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,74 @@
1
+ require "terrestrial/short_inspection_string"
2
+
3
+ module Terrestrial
4
+ class LazyCollection
5
+ include ShortInspectionString
6
+ include Enumerable
7
+
8
+ def initialize(database_enum, loader, queries)
9
+ @database_enum = database_enum
10
+ @loader = loader
11
+ @queries = queries
12
+ @loaded = false
13
+ end
14
+
15
+ attr_reader :database_enum, :loader, :queries
16
+ private :database_enum, :loader, :queries
17
+
18
+ def where(criteria)
19
+ self.class.new(database_enum.where(criteria), loader, queries)
20
+ end
21
+
22
+ def subset(name, *params)
23
+ self.class.new(
24
+ queries.execute(database_enum, name, *params),
25
+ loader,
26
+ queries,
27
+ )
28
+ end
29
+
30
+ def to_ary
31
+ to_a
32
+ end
33
+
34
+ def each(&block)
35
+ enum.each(&block)
36
+ end
37
+
38
+ def each_loaded(&block)
39
+ loaded_objects.each(&block)
40
+ end
41
+
42
+ private
43
+
44
+ def enum
45
+ @enum ||= Enumerator.new { |yielder|
46
+ loaded_objects.each do |obj|
47
+ yielder.yield(obj)
48
+ end
49
+
50
+ loop do
51
+ object_enum.next.tap { |obj|
52
+ loaded_objects.push(obj)
53
+ yielder.yield(obj)
54
+ }
55
+ end
56
+ }
57
+ end
58
+
59
+ def object_enum
60
+ @object_enum ||= database_enum.lazy.map(&loader)
61
+ end
62
+
63
+ def loaded_objects
64
+ @loaded_objects ||= []
65
+ end
66
+
67
+ def inspectable_properties
68
+ [
69
+ :database_enum,
70
+ :loaded,
71
+ ]
72
+ end
73
+ end
74
+ end