terrestrial 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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