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,313 @@
1
+ require "spec_helper"
2
+
3
+ require "support/have_persisted_matcher"
4
+ require "support/mapper_setup"
5
+ require "support/sequel_persistence_setup"
6
+ require "support/seed_data_setup"
7
+ require "terrestrial"
8
+
9
+ RSpec.describe "Graph persistence" do
10
+ include_context "mapper setup"
11
+ include_context "sequel persistence setup"
12
+ include_context "seed data setup"
13
+
14
+ subject(:mapper) { mappers.fetch(:users) }
15
+
16
+ let(:user) {
17
+ mapper.where(id: "users/1").first
18
+ }
19
+
20
+ context "without associations" do
21
+ let(:modified_email) { "bestie+modified@gmail.com" }
22
+
23
+ it "saves the root object" do
24
+ user.email = modified_email
25
+ mapper.save(user)
26
+
27
+ expect(datastore).to have_persisted(
28
+ :users,
29
+ hash_including(
30
+ id: "users/1",
31
+ email: modified_email,
32
+ )
33
+ )
34
+ end
35
+
36
+ it "doesn't send associated objects to the database as columns" do
37
+ user.email = modified_email
38
+ mapper.save(user)
39
+
40
+ expect(datastore).not_to have_persisted(
41
+ :users,
42
+ hash_including(
43
+ posts: anything,
44
+ )
45
+ )
46
+ end
47
+
48
+ # TODO move to a dirty tracking spec?
49
+ context "when mutating entity fields in place" do
50
+ it "saves the object" do
51
+ user.email << "MUTATED"
52
+
53
+ mapper.save(user)
54
+
55
+ expect(datastore).to have_persisted(
56
+ :users,
57
+ hash_including(
58
+ id: "users/1",
59
+ email: /MUTATED$/,
60
+ )
61
+ )
62
+ end
63
+ end
64
+ end
65
+
66
+ context "modify shallow has many associated object" do
67
+ let(:post) { user.posts.first }
68
+ let(:modified_post_body) { "modified ur body" }
69
+
70
+ it "saves the associated object" do
71
+ post.body = modified_post_body
72
+ mapper.save(user)
73
+
74
+ expect(datastore).to have_persisted(
75
+ :posts,
76
+ hash_including(
77
+ id: post.id,
78
+ subject: post.subject,
79
+ author_id: user.id,
80
+ body: modified_post_body,
81
+ )
82
+ )
83
+ end
84
+ end
85
+
86
+ context "modify deeply nested has many associated object" do
87
+ let(:comment) {
88
+ user.posts.first.comments.first
89
+ }
90
+
91
+ let(:modified_comment_body) { "body moving, body moving" }
92
+
93
+ it "saves the associated object" do
94
+ comment.body = modified_comment_body
95
+ mapper.save(user)
96
+
97
+ expect(datastore).to have_persisted(
98
+ :comments,
99
+ hash_including(
100
+ {
101
+ id: "comments/1",
102
+ post_id: "posts/1",
103
+ commenter_id: "users/1",
104
+ body: modified_comment_body,
105
+ }
106
+ )
107
+ )
108
+ end
109
+ end
110
+
111
+ context "add a node to a has many association" do
112
+ let(:new_post_attrs) {
113
+ {
114
+ id: "posts/neu",
115
+ subject: "I am new",
116
+ body: "new body",
117
+ comments: [],
118
+ categories: [],
119
+ created_at: Time.now,
120
+ }
121
+ }
122
+
123
+ let(:new_post) {
124
+ Post.new(new_post_attrs)
125
+ }
126
+
127
+ it "adds the object to the graph" do
128
+ user.posts.push(new_post)
129
+
130
+ expect(user.posts).to include(new_post)
131
+ end
132
+
133
+ it "persists the object" do
134
+ user.posts.push(new_post)
135
+
136
+ mapper.save(user)
137
+
138
+ expect(datastore).to have_persisted(
139
+ :posts,
140
+ hash_including(
141
+ id: "posts/neu",
142
+ author_id: user.id,
143
+ subject: "I am new",
144
+ )
145
+ )
146
+ end
147
+ end
148
+
149
+ context "delete an object from a has many association" do
150
+ let(:post) { user.posts.first }
151
+
152
+ it "delete the object from the graph" do
153
+ user.posts.delete(post)
154
+
155
+ expect(user.posts.map(&:id)).not_to include(post.id)
156
+ end
157
+
158
+ it "delete the object from the datastore on save" do
159
+ user.posts.delete(post)
160
+ mapper.save(user)
161
+
162
+ expect(datastore).not_to have_persisted(
163
+ :posts,
164
+ hash_including(
165
+ id: post.id,
166
+ )
167
+ )
168
+ end
169
+ end
170
+
171
+ context "modify a many to many relationship" do
172
+ let(:post) { user.posts.first }
173
+
174
+ context "delete a node" do
175
+ it "mutates the graph" do
176
+ category = post.categories.first
177
+ post.categories.delete(category)
178
+
179
+ expect(post.categories.map(&:id)).not_to include(category.id)
180
+ end
181
+
182
+ it "deletes the 'join table' record" do
183
+ category = post.categories.first
184
+ post.categories.delete(category)
185
+ mapper.save(user)
186
+
187
+ expect(datastore).not_to have_persisted(
188
+ :categories_to_posts,
189
+ {
190
+ post_id: post.id,
191
+ category_id: category.id,
192
+ }
193
+ )
194
+ end
195
+
196
+ it "does not delete the object" do
197
+ category = post.categories.first
198
+ post.categories.delete(category)
199
+ mapper.save(user)
200
+
201
+ expect(datastore).to have_persisted(
202
+ :categories,
203
+ hash_including(
204
+ id: category.id,
205
+ )
206
+ )
207
+ end
208
+ end
209
+
210
+ context "add a node" do
211
+ let(:post_with_one_category) { user.posts.to_a.last }
212
+ let(:new_category) { user.posts.first.categories.to_a.first }
213
+
214
+ it "mutates the graph" do
215
+ post_with_one_category.categories.push(new_category)
216
+
217
+ expect(post_with_one_category.categories.map(&:id))
218
+ .to match_array(["categories/1", "categories/2"])
219
+ end
220
+
221
+ it "persists the change" do
222
+ post_with_one_category.categories.push(new_category)
223
+ mapper.save(user)
224
+
225
+ expect(datastore).to have_persisted(
226
+ :categories_to_posts,
227
+ {
228
+ post_id: post_with_one_category.id,
229
+ category_id: new_category.id,
230
+ }
231
+ )
232
+ end
233
+ end
234
+
235
+ context "modify a node" do
236
+ let(:category) { user.posts.first.categories.first }
237
+ let(:modified_category_name) { "modified category" }
238
+
239
+ it "mutates the graph" do
240
+ category.name = modified_category_name
241
+
242
+ expect(post.categories.first.name)
243
+ .to eq(modified_category_name)
244
+ end
245
+
246
+ it "persists the change" do
247
+ category.name = modified_category_name
248
+ mapper.save(user)
249
+
250
+ expect(datastore).to have_persisted(
251
+ :categories,
252
+ {
253
+ id: category.id,
254
+ name: modified_category_name,
255
+ }
256
+ )
257
+ end
258
+ end
259
+
260
+ context "node loaded as root has undefined one to many association" do
261
+ let(:post_mapper) { mappers[:posts] }
262
+ let(:post) { post_mapper.where(id: "posts/1").first }
263
+
264
+ it "persists the changes to the root node" do
265
+ post.body = "modified body"
266
+
267
+ post_mapper.save(post)
268
+
269
+ expect(datastore).to have_persisted(
270
+ :posts,
271
+ hash_including(
272
+ id: "posts/1",
273
+ body: "modified body",
274
+ )
275
+ )
276
+ end
277
+
278
+ it "does not overwrite unused foreign key" do
279
+ post.body = "modified body"
280
+
281
+ post_mapper.save(post)
282
+
283
+ expect(datastore).to have_persisted(
284
+ :posts,
285
+ hash_including(
286
+ id: "posts/1",
287
+ author_id: "users/1",
288
+ )
289
+ )
290
+ end
291
+ end
292
+ end
293
+
294
+ context "when a save operation fails (some object is not persistable)" do
295
+ let(:unpersistable_object) { ->() { } }
296
+
297
+ it "rolls back the transaction" do
298
+ pre_change = datastore[:users].to_a.map(&:to_a).sort
299
+
300
+ begin
301
+ user.first_name = "this will be rolled back"
302
+ user.posts.first.subject = unpersistable_object
303
+
304
+ mapper.save(user)
305
+ rescue Sequel::Error
306
+ end
307
+
308
+ post_change = datastore[:users].to_a.map(&:to_a).sort
309
+
310
+ expect(pre_change).to eq(post_change)
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,121 @@
1
+ require "spec_helper"
2
+
3
+ require "support/mapper_setup"
4
+ require "support/sequel_persistence_setup"
5
+ require "support/seed_data_setup"
6
+ require "terrestrial"
7
+
8
+ RSpec.describe "Graph traversal" do
9
+ include_context "mapper setup"
10
+ include_context "sequel persistence setup"
11
+ include_context "seed data setup"
12
+
13
+ describe "associations" do
14
+ subject(:mapper) { user_mapper }
15
+
16
+ let(:user_query) {
17
+ mapper.where(id: "users/1")
18
+ }
19
+
20
+ let(:user) { user_query.first }
21
+
22
+ it "finds data via the storage adapter" do
23
+ expect(user_query.count).to eq(1)
24
+ end
25
+
26
+ it "maps the raw data from the store into domain objects" do
27
+ expect(user_query.first.id).to eq("users/1")
28
+ expect(user_query.first.first_name).to eq("Hansel")
29
+ end
30
+
31
+ it "handles has_many associations" do
32
+ post = user.posts.first
33
+
34
+ expect(post.subject).to eq("Biscuits")
35
+ end
36
+
37
+ it "handles nested has_many associations" do
38
+ expect(
39
+ user
40
+ .posts.first
41
+ .comments.first
42
+ .body
43
+ ).to eq("oh noes")
44
+ end
45
+
46
+ describe "lazy loading" do
47
+ let(:post_factory) { double(:post_factory, call: nil) }
48
+
49
+ it "loads has many associations lazily" do
50
+ posts = user_query.first.posts
51
+
52
+ expect(post_factory).not_to have_received(:call)
53
+ end
54
+ end
55
+
56
+ it "maps belongs to associations" do
57
+ post = user.posts.first
58
+ comment = post.comments.first
59
+
60
+ expect(comment.commenter.id).to eq("users/1")
61
+ end
62
+
63
+ describe "identity map" do
64
+ it "always returns (a proxy of) the same object for a given id" do
65
+ post = user.posts.first
66
+ comment = post.comments.first
67
+
68
+ expect(comment.commenter.__getobj__)
69
+ .to be(user)
70
+ end
71
+ end
72
+
73
+ it "maps deeply nested belongs to associations" do
74
+ expect(user_query.first.posts.first.comments.first.commenter.id)
75
+ .to eq("users/1")
76
+ end
77
+
78
+ it "maps has many to many associations as has many through" do
79
+ expect(user_query.first.posts.first.categories.map(&:id))
80
+ .to match_array(["categories/1", "categories/2"])
81
+
82
+ expect(user_query.first.posts.first.categories.to_a.last.posts.map(&:id))
83
+ .to match_array(["posts/1", "posts/2", "posts/3"])
84
+ end
85
+
86
+ describe "eager_loading" do
87
+ it "returns the expected objects" do
88
+ expect(
89
+ user_query
90
+ .eager_load(:posts => { :categories => { :posts => [] }})
91
+ .flat_map(&:posts)
92
+ .flat_map(&:categories)
93
+ .map(&:posts)
94
+ .map { |collection| collection.map(&:id) }
95
+ ).to eq([["posts/1"]] + [["posts/1", "posts/2", "posts/3"]] * 2)
96
+ end
97
+
98
+ context "when traversing beyond the eager loaded data" do
99
+ it "returns the expected objects" do
100
+ expect(
101
+ user_query
102
+ .eager_load(:posts => { :categories => { :posts => [] }})
103
+ .flat_map(&:posts)
104
+ .flat_map(&:categories)
105
+ .flat_map(&:posts)
106
+ .flat_map(&:categories)
107
+ .flat_map(&:posts)
108
+ .flat_map(&:categories)
109
+ .uniq
110
+ .map(&:id)
111
+ ).to eq([
112
+ "categories/1",
113
+ "categories/2",
114
+ "categories/3",
115
+ "categories/4",
116
+ ])
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,71 @@
1
+ require "spec_helper"
2
+ require "support/mapper_setup"
3
+ require "support/sequel_persistence_setup"
4
+ require "support/have_persisted_matcher"
5
+
6
+ RSpec.describe "Persist a new graph in empty datastore" do
7
+ include_context "mapper setup"
8
+ include_context "sequel persistence setup"
9
+
10
+ context "given a graph of new objects" do
11
+ it "persists the root node" do
12
+ user_mapper.save(hansel)
13
+
14
+ expect(datastore).to have_persisted(:users, {
15
+ id: hansel.id,
16
+ first_name: hansel.first_name,
17
+ last_name: hansel.last_name,
18
+ email: hansel.email,
19
+ })
20
+ end
21
+
22
+ it "persists one to many related nodes 1 level deep" do
23
+ user_mapper.save(hansel)
24
+
25
+ expect(datastore).to have_persisted(:posts, hash_including(
26
+ id: "posts/1",
27
+ subject: "Biscuits",
28
+ body: "I like them",
29
+ author_id: "users/1",
30
+ ))
31
+
32
+ expect(datastore).to have_persisted(:posts, hash_including(
33
+ id: "posts/2",
34
+ subject: "Sleeping",
35
+ body: "I do it three times purrr day",
36
+ author_id: "users/1",
37
+ ))
38
+ end
39
+
40
+ context "deep node with two foreign keys" do
41
+ it "persists the node with both foreign keys" do
42
+ user_mapper.save(hansel)
43
+
44
+ expect(datastore).to have_persisted(:comments, {
45
+ id: "comments/1",
46
+ body: "oh noes",
47
+ post_id: "posts/1",
48
+ commenter_id: "users/1",
49
+ })
50
+ end
51
+ end
52
+
53
+ it "persists many to many related nodes" do
54
+ user_mapper.save(hansel)
55
+
56
+ expect(datastore).to have_persisted(:categories, {
57
+ id: "categories/1",
58
+ name: "Cat biscuits",
59
+ })
60
+ end
61
+
62
+ it "persists a 'join table' to faciliate many to many" do
63
+ user_mapper.save(hansel)
64
+
65
+ expect(datastore).to have_persisted(:categories_to_posts, {
66
+ category_id: "categories/1",
67
+ post_id: "posts/1",
68
+ })
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,70 @@
1
+ require "spec_helper"
2
+
3
+ require "support/mapper_setup"
4
+ require "support/sequel_persistence_setup"
5
+ require "support/seed_data_setup"
6
+ require "terrestrial"
7
+
8
+ RSpec.describe "Object identity" do
9
+ include_context "mapper setup"
10
+ include_context "sequel persistence setup"
11
+ include_context "seed data setup"
12
+
13
+ subject(:mapper) { mappers.fetch(:users) }
14
+
15
+ let(:user) { mapper.where(id: "users/1").first }
16
+ let(:post) { user.posts.first }
17
+
18
+ context "when using arbitrary where query" do
19
+ it "returns the same object for a row's primary key" do
20
+ expect(
21
+ user.posts.where(id: post.id).first
22
+ ).to be(post)
23
+ end
24
+ end
25
+
26
+ context "when traversing deep into the graph" do
27
+ context "via has many through" do
28
+ it "returns the same object for a row's primary key" do
29
+ expect(
30
+ user.posts.first.categories.first.posts
31
+ .find { |cat_post| cat_post.id == post.id }
32
+ ).to be(post)
33
+ end
34
+ end
35
+
36
+ context "via a belongs to" do
37
+ it "returns the same object for a row's primary once loaded" do
38
+ # TODO: Add another method to avoid using #__getobj__
39
+ expect(
40
+ user.posts.first.comments
41
+ .find { |comment| comment.commenter.id == user.id }
42
+ .commenter
43
+ .__getobj__
44
+ ).to be(user)
45
+ end
46
+ end
47
+
48
+ context "when eager loading" do
49
+ let(:user_query) { mapper.where(id: "users/1") }
50
+
51
+ let(:eager_category) {
52
+ user_query
53
+ .eager_load(:posts => { :categories => { :posts => [] }})
54
+ .first
55
+ .posts
56
+ .first
57
+ .categories
58
+ .first
59
+ }
60
+
61
+ it "returns the same object for a row's primary once loaded" do
62
+ expect(
63
+ eager_category
64
+ .posts
65
+ .find { |cat_post| cat_post.id == post.id }
66
+ ).to be(post)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,51 @@
1
+ require "spec_helper"
2
+
3
+ require "support/mapper_setup"
4
+ require "support/sequel_persistence_setup"
5
+ require "support/seed_data_setup"
6
+ require "terrestrial"
7
+ require "terrestrial/configurations/conventional_configuration"
8
+
9
+ RSpec.describe "Ordered associations" do
10
+ include_context "mapper setup"
11
+ include_context "sequel persistence setup"
12
+ include_context "seed data setup"
13
+
14
+ context "one to many association ordered by `created_at DESC`" do
15
+ let(:posts) { user_mapper.first.posts }
16
+
17
+ before do
18
+ configs.fetch(:users).fetch(:associations).fetch(:posts).merge!(
19
+ order: Terrestrial::QueryOrder.new(
20
+ fields: [:created_at],
21
+ direction: "DESC",
22
+ )
23
+ )
24
+ end
25
+
26
+ it "enumerates the objects in order specified in the config" do
27
+ expect(posts.map(&:id)).to eq(
28
+ posts.to_a.sort_by(&:created_at).reverse.map(&:id)
29
+ )
30
+ end
31
+ end
32
+
33
+ context "many to many associatin ordered by reverse alphabetical name" do
34
+ before do
35
+ configs.fetch(:posts).fetch(:associations).fetch(:categories).merge!(
36
+ order: Terrestrial::QueryOrder.new(
37
+ fields: [:name],
38
+ direction: "DESC",
39
+ )
40
+ )
41
+ end
42
+
43
+ let(:categories) { user_mapper.first.posts.first.categories }
44
+
45
+ it "enumerates the objects in order specified in the config" do
46
+ expect(categories.map(&:id)).to eq(
47
+ categories.to_a.sort_by(&:name).reverse.map(&:id)
48
+ )
49
+ end
50
+ end
51
+ end