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,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