terrestrial 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/Gemfile.lock +44 -53
  4. data/README.md +3 -6
  5. data/bin/test +1 -1
  6. data/features/env.rb +12 -2
  7. data/features/example.feature +23 -26
  8. data/lib/terrestrial.rb +31 -0
  9. data/lib/terrestrial/adapters/abstract_adapter.rb +6 -0
  10. data/lib/terrestrial/adapters/memory_adapter.rb +82 -6
  11. data/lib/terrestrial/adapters/sequel_postgres_adapter.rb +191 -0
  12. data/lib/terrestrial/configurations/conventional_association_configuration.rb +65 -35
  13. data/lib/terrestrial/configurations/conventional_configuration.rb +280 -124
  14. data/lib/terrestrial/configurations/mapping_config_options_proxy.rb +97 -0
  15. data/lib/terrestrial/deleted_record.rb +12 -8
  16. data/lib/terrestrial/dirty_map.rb +17 -9
  17. data/lib/terrestrial/functional_pipeline.rb +64 -0
  18. data/lib/terrestrial/inspection_string.rb +6 -1
  19. data/lib/terrestrial/lazy_object_proxy.rb +1 -0
  20. data/lib/terrestrial/many_to_many_association.rb +34 -20
  21. data/lib/terrestrial/many_to_one_association.rb +11 -3
  22. data/lib/terrestrial/one_to_many_association.rb +9 -0
  23. data/lib/terrestrial/public_conveniencies.rb +65 -82
  24. data/lib/terrestrial/record.rb +106 -0
  25. data/lib/terrestrial/relation_mapping.rb +43 -12
  26. data/lib/terrestrial/relational_store.rb +33 -11
  27. data/lib/terrestrial/upsert_record.rb +54 -0
  28. data/lib/terrestrial/version.rb +1 -1
  29. data/spec/automatic_timestamps_spec.rb +339 -0
  30. data/spec/changes_api_spec.rb +81 -0
  31. data/spec/config_override_spec.rb +28 -19
  32. data/spec/custom_serializers_spec.rb +3 -2
  33. data/spec/database_default_fields_spec.rb +213 -0
  34. data/spec/database_generated_id_spec.rb +291 -0
  35. data/spec/database_owned_fields_and_timestamps_spec.rb +200 -0
  36. data/spec/deletion_spec.rb +1 -1
  37. data/spec/error_handling/factory_error_handling_spec.rb +1 -4
  38. data/spec/error_handling/serialization_error_spec.rb +1 -4
  39. data/spec/error_handling/upsert_error_spec.rb +7 -11
  40. data/spec/graph_persistence_spec.rb +52 -18
  41. data/spec/ordered_association_spec.rb +10 -12
  42. data/spec/predefined_queries_spec.rb +14 -12
  43. data/spec/readme_examples_spec.rb +1 -1
  44. data/spec/sequel_query_efficiency_spec.rb +19 -16
  45. data/spec/spec_helper.rb +6 -1
  46. data/spec/support/blog_schema.rb +7 -3
  47. data/spec/support/object_graph_setup.rb +30 -39
  48. data/spec/support/object_store_setup.rb +16 -196
  49. data/spec/support/seed_data_setup.rb +15 -149
  50. data/spec/support/seed_records.rb +141 -0
  51. data/spec/support/sequel_test_support.rb +46 -13
  52. data/spec/terrestrial/abstract_record_spec.rb +138 -106
  53. data/spec/terrestrial/adapters/sequel_postgres_adapter_spec.rb +138 -0
  54. data/spec/terrestrial/deleted_record_spec.rb +0 -27
  55. data/spec/terrestrial/dirty_map_spec.rb +52 -77
  56. data/spec/terrestrial/functional_pipeline_spec.rb +153 -0
  57. data/spec/terrestrial/inspection_string_spec.rb +61 -0
  58. data/spec/terrestrial/upsert_record_spec.rb +29 -0
  59. data/terrestrial.gemspec +7 -8
  60. metadata +43 -40
  61. data/MissingFeatures.md +0 -64
  62. data/lib/terrestrial/abstract_record.rb +0 -99
  63. data/lib/terrestrial/association_loaders.rb +0 -52
  64. data/lib/terrestrial/upserted_record.rb +0 -15
  65. data/spec/terrestrial/public_conveniencies_spec.rb +0 -63
  66. data/spec/terrestrial/upserted_record_spec.rb +0 -59
@@ -0,0 +1,200 @@
1
+ require "spec_helper"
2
+
3
+ require "support/have_persisted_matcher"
4
+ require "support/object_store_setup"
5
+ require "support/seed_data_setup"
6
+
7
+ # Because some systems use triggers to generate/maintain timestamps at the
8
+ # database level they are used as an example for field that the database owns.
9
+ # This means the application layer should always read from and never write to
10
+ # that field.
11
+ RSpec.describe "Database owned fields", backend: "sequel" do
12
+ include_context "object store setup"
13
+
14
+ before(:all) do
15
+ create_db_timestamp_tables
16
+ end
17
+
18
+ after(:all) do
19
+ drop_db_timestamp_tables
20
+ end
21
+
22
+ let(:user_store) {
23
+ object_store[:users]
24
+ }
25
+
26
+ let(:object_store) {
27
+ Terrestrial.object_store(config: with_db_owned_fields_config)
28
+ }
29
+
30
+ let(:user_with_post) {
31
+ User.new(
32
+ id: "users/1",
33
+ first_name: "Hansel",
34
+ last_name: "Trickett",
35
+ email: "hansel@tricketts.org",
36
+ posts: [post],
37
+ )
38
+ }
39
+
40
+ let(:post) {
41
+ Post.new(
42
+ id: "posts/1",
43
+ author: nil,
44
+ subject: "Biscuits",
45
+ body: "I like them",
46
+ comments: [],
47
+ categories: [],
48
+ created_at: nil,
49
+ updated_at: nil,
50
+ )
51
+ }
52
+
53
+ let(:with_db_owned_fields_config) {
54
+ Terrestrial.config(datastore)
55
+ .setup_mapping(:users) { |users|
56
+ users.has_many(:posts, foreign_key: :author_id)
57
+ }
58
+ .setup_mapping(:posts) { |posts|
59
+ posts.relation_name(:timestamped_posts)
60
+ posts.database_owned_field(:created_at)
61
+ posts.database_owned_field(:updated_at)
62
+ }
63
+ }
64
+
65
+ context "new objects" do
66
+ it "adds the current time to the timestamp fields" do
67
+ user_store.save(user_with_post)
68
+
69
+ expect(datastore).to have_persisted(
70
+ :timestamped_posts,
71
+ hash_including(
72
+ created_at: an_instance_of(Time),
73
+ )
74
+ )
75
+ end
76
+
77
+ it "updates the objects with the new timestamp values" do
78
+ expect(post).to receive(:created_at=).with(an_instance_of(Time))
79
+
80
+ user_store.save(user_with_post)
81
+ end
82
+
83
+ it "does not insert values for the database owned fields" do
84
+ user_store.save(user_with_post)
85
+
86
+ posts_insert = query_counter
87
+ .inserts
88
+ .select { |sql| sql.include?("timestamped_posts") }
89
+ .fetch(0)
90
+
91
+ expect(posts_insert).not_to include("created_at")
92
+ end
93
+ end
94
+
95
+ context "updating existing objects" do
96
+ before do
97
+ user_store.save(user_with_post)
98
+ post.body = "new body"
99
+ end
100
+
101
+ context "when the database owned value does not change" do
102
+ it "regardless, updates the object with the returned value" do
103
+ expect(post).to receive(:created_at=).with(an_instance_of(Time))
104
+
105
+ user_store.save(user_with_post)
106
+ end
107
+ end
108
+
109
+ context "when the value changes in the database (without worrying about how)" do
110
+ before do
111
+ datastore[:timestamped_posts]
112
+ .where(id: post.id)
113
+ .update("created_at" => party_time)
114
+ end
115
+
116
+ let(:party_time) { Time.parse("1999-01-01 00:00:00 UTC") }
117
+
118
+ it "regardless, updates the object with the returned value" do
119
+ expect(post).to receive(:created_at=).with(party_time)
120
+
121
+ user_store.save(user_with_post)
122
+ end
123
+ end
124
+
125
+ context "when the database owned value is changed in the object" do
126
+ it "reverts the object value back to the database value (you may wish to prevent overwriting in your domain model)" do
127
+ original_time = post.created_at
128
+ party_time = Time.parse("1999-01-01 00:00:00 UTC")
129
+ post.created_at = party_time
130
+
131
+ expect(post).to receive(:created_at=).with(original_time)
132
+
133
+ user_store.save(user_with_post)
134
+ end
135
+
136
+ it "does not insert values for the database owned fields" do
137
+ user_store.save(user_with_post)
138
+
139
+ posts_insert = query_counter
140
+ .inserts
141
+ .select { |sql| sql.include?("timestamped_posts") }
142
+ .fetch(0)
143
+
144
+ expect(posts_insert).not_to include("created_at")
145
+ end
146
+ end
147
+ end
148
+
149
+ def schema
150
+ {
151
+ :tables => {
152
+ :timestamped_posts => [
153
+ {
154
+ :name => :id,
155
+ :type => String,
156
+ :options => {
157
+ :primary_key => true,
158
+ }
159
+ },
160
+ {
161
+ :name => :subject,
162
+ :type => String,
163
+ },
164
+ {
165
+ :name => :body,
166
+ :type => String,
167
+ },
168
+ {
169
+ :name => :author_id,
170
+ :type => String,
171
+ },
172
+ {
173
+ :name => :created_at,
174
+ :type => DateTime,
175
+ :options => {
176
+ :default => Sequel::CURRENT_TIMESTAMP,
177
+ :null => false,
178
+ },
179
+ },
180
+ {
181
+ :name => :updated_at,
182
+ :type => DateTime,
183
+ :options => {
184
+ :default => Sequel::CURRENT_TIMESTAMP,
185
+ :null => false,
186
+ },
187
+ },
188
+ ],
189
+ },
190
+ }
191
+ end
192
+
193
+ def create_db_timestamp_tables
194
+ Terrestrial::SequelTestSupport.create_tables(schema.fetch(:tables))
195
+ end
196
+
197
+ def drop_db_timestamp_tables
198
+ Terrestrial::SequelTestSupport.drop_tables(schema.fetch(:tables).keys)
199
+ end
200
+ end
@@ -21,7 +21,7 @@ RSpec.describe "Deletion" do
21
21
 
22
22
  describe "Deleting the root" do
23
23
  it "deletes the root object" do
24
- user_store.delete(user, cascade: true)
24
+ user_store.delete(user)
25
25
 
26
26
  expect(datastore).not_to have_persisted(
27
27
  :users,
@@ -49,10 +49,7 @@ RSpec.describe "factory error handling" do
49
49
  users.factory(factory)
50
50
  }
51
51
 
52
- @object_store = Terrestrial.object_store(
53
- mappings: config,
54
- datastore: datastore,
55
- )
52
+ @object_store = Terrestrial.object_store(config: config)
56
53
  end
57
54
 
58
55
  def seed_user(record)
@@ -37,10 +37,7 @@ RSpec.describe "Serialization error handling" do
37
37
  users.serializer(serializer)
38
38
  }
39
39
 
40
- @object_store = Terrestrial.object_store(
41
- mappings: config,
42
- datastore: datastore,
43
- )
40
+ @object_store = Terrestrial.object_store(config: config)
44
41
  end
45
42
 
46
43
  def save_user
@@ -104,13 +104,12 @@ RSpec.describe "Upsert error handling" do
104
104
  rescue Terrestrial::UpsertError => error
105
105
  end
106
106
 
107
- expect(error.message).to eq(
108
- [
109
- "Error upserting record into `users` with data `#{serialization_result}`.",
110
- "Got Error: Sequel::NotNullConstraintViolation PG::NotNullViolation: ERROR: null value in column \"id\" violates not-null constraint",
111
- "DETAIL: Failing row contains (null, Hansel, Trickett, hansel@tricketts.org).\n",
112
- ].join("\n")
113
- )
107
+ aggregate_failures do
108
+ expect(error.message).to start_with("Error upserting record into `users` with data `#{serialization_result}`.")
109
+ expect(error.message).to include("Got Error: Sequel::NotNullConstraintViolation")
110
+ expect(error.message).to include("in column \"id\"")
111
+ expect(error.message).to include("DETAIL: Failing row contains (null, Hansel, Trickett, hansel@tricketts.org)")
112
+ end
114
113
  end
115
114
  end
116
115
 
@@ -124,9 +123,6 @@ RSpec.describe "Upsert error handling" do
124
123
  users.serializer(serializer)
125
124
  }
126
125
 
127
- @object_store = Terrestrial.object_store(
128
- mappings: config,
129
- datastore: datastore,
130
- )
126
+ @object_store = Terrestrial.object_store(config: config)
131
127
  end
132
128
  end
@@ -31,21 +31,8 @@ RSpec.describe "Graph persistence" do
31
31
  )
32
32
  end
33
33
 
34
- it "doesn't send associated objects to the database as columns" do
35
- user.email = modified_email
36
- user_store.save(user)
37
-
38
- expect(datastore).not_to have_persisted(
39
- :users,
40
- hash_including(
41
- posts: anything,
42
- )
43
- )
44
- end
45
-
46
- # TODO move to a dirty tracking spec?
47
- context "when mutating entity fields in place" do
48
- it "saves the object" do
34
+ context "when mutating an entity's fields in place" do
35
+ it "updates the row with new, mutated values" do
49
36
  user.email << "MUTATED"
50
37
 
51
38
  user_store.save(user)
@@ -110,11 +97,13 @@ RSpec.describe "Graph persistence" do
110
97
  let(:new_post_attrs) {
111
98
  {
112
99
  id: "posts/neu",
100
+ author: nil,
113
101
  subject: "I am new",
114
102
  body: "new body",
115
103
  comments: [],
116
104
  categories: [],
117
105
  created_at: Time.now,
106
+ updated_at: Time.now,
118
107
  }
119
108
  }
120
109
 
@@ -242,6 +231,47 @@ RSpec.describe "Graph persistence" do
242
231
  end
243
232
  end
244
233
 
234
+ context "duplicate a node" do
235
+ let(:post_with_one_category) { user.posts.to_a.last }
236
+
237
+ # Spoiler alert: it does mutate the graph
238
+ #
239
+ # Feature?: The posts <=> category relationship because unique when persisted
240
+ # because there are no indexes on the `categories_to_posts` table making
241
+ # the combination of foreign keys a de facto primary key.
242
+ #
243
+ # If there was an additional primary key id field without a unique index
244
+ # this would not be the case.
245
+ #
246
+ # It would be nice if the collection proxy for posts <=> categories was a
247
+ # variant that behaved like set. Unfortunately uniqueness can only be
248
+ # determined by the user-defind objects' identities as the proxy would
249
+ # not have access to datastore ids.
250
+ #
251
+ # Mappings are available when the proxy is constructed so this is
252
+ # possible but awkward.
253
+ xit "does not mutate the graph" do
254
+ existing_category = post_with_one_category.categories.first
255
+ post_with_one_category.categories.push(existing_category)
256
+
257
+ expect(post_with_one_category.categories.map(&:id))
258
+ .to eq(["categories/2"])
259
+ end
260
+
261
+ it "does not persist the change" do
262
+ existing_category = post_with_one_category.categories.first
263
+ post_with_one_category.categories.push(existing_category)
264
+
265
+ user_store.save(user)
266
+
267
+ expect(
268
+ datastore[:categories_to_posts]
269
+ .where(:post_id => post_with_one_category.id)
270
+ .count
271
+ ).to eq(1)
272
+ end
273
+ end
274
+
245
275
  context "modify a node" do
246
276
  let(:category) { user.posts.first.categories.first }
247
277
  let(:modified_category_name) { "modified category" }
@@ -306,9 +336,11 @@ RSpec.describe "Graph persistence" do
306
336
  it "populates that association with a nil" do
307
337
  post = Post.new(
308
338
  id: "posts/orphan",
339
+ author: nil,
309
340
  subject: "Nils gonna getcha",
310
341
  body: "",
311
342
  created_at: Time.parse("2015-09-05T15:00:00+01:00"),
343
+ updated_at: Time.parse("2015-09-05T15:00:00+01:00"),
312
344
  categories: [],
313
345
  comments: [],
314
346
  )
@@ -326,7 +358,7 @@ RSpec.describe "Graph persistence" do
326
358
  end
327
359
 
328
360
  context "when an existing partent object reference is set to nil" do
329
- it "populates that association with a nil" do
361
+ it "does not orphan the object and sets the foreign key according to position in the object graph" do
330
362
  comment = user
331
363
  .posts
332
364
  .flat_map(&:comments)
@@ -340,7 +372,7 @@ RSpec.describe "Graph persistence" do
340
372
  :comments,
341
373
  hash_including(
342
374
  id: "comments/1",
343
- commenter_id: nil,
375
+ commenter_id: "users/1",
344
376
  )
345
377
  )
346
378
  end
@@ -358,9 +390,11 @@ RSpec.describe "Graph persistence" do
358
390
  user.posts.first.subject = unpersistable_object
359
391
 
360
392
  user_store.save(user)
361
- rescue Terrestrial::Error
393
+ rescue Object => e
362
394
  end
363
395
 
396
+ expect(e).to be_a(Terrestrial::Error)
397
+
364
398
  post_change = datastore[:users].to_a.map(&:to_a).sort
365
399
 
366
400
  expect(pre_change).to eq(post_change)
@@ -13,12 +13,10 @@ RSpec.describe "Ordered associations" do
13
13
  let(:posts) { object_store[:users].first.posts }
14
14
 
15
15
  before do
16
- configs.fetch(:users).fetch(:associations).fetch(:posts).merge!(
17
- order: Terrestrial::QueryOrder.new(
18
- fields: [:created_at],
19
- direction: "DESC",
20
- )
21
- )
16
+ mappings
17
+ .setup_mapping(:users) { |users|
18
+ users.has_many(:posts, foreign_key: :author_id, order_fields: [:created_at], order_direction: :DESC)
19
+ }
22
20
  end
23
21
 
24
22
  it "enumerates the objects in order specified in the config" do
@@ -30,12 +28,12 @@ RSpec.describe "Ordered associations" do
30
28
 
31
29
  context "many to many associatin ordered by reverse alphabetical name" do
32
30
  before do
33
- configs.fetch(:posts).fetch(:associations).fetch(:categories).merge!(
34
- order: Terrestrial::QueryOrder.new(
35
- fields: [:name],
36
- direction: "DESC",
37
- )
38
- )
31
+ mappings
32
+ .setup_mapping(:posts) { |posts|
33
+ posts.fields([:id, :subject, :body, :created_at, :updated_at])
34
+ posts.has_many(:comments)
35
+ posts.has_many_through(:categories, order_fields: [:name], order_direction: :DESC)
36
+ }
39
37
  end
40
38
 
41
39
  let(:categories) { object_store[:users].first.posts.first.categories }
@@ -14,15 +14,15 @@ RSpec.describe "Predefined subset queries" do
14
14
  context "on the top level mapper" do
15
15
  context "subset is defined with a block" do
16
16
  before do
17
- configs.fetch(:users).merge!(
18
- subsets: {
19
- tricketts: ->(dataset) {
17
+ mappings
18
+ .setup_mapping(:users) { |users|
19
+ users.has_many(:posts, foreign_key: :author_id)
20
+ users.subset(:tricketts) { |dataset|
20
21
  dataset
21
22
  .where(last_name: "Trickett")
22
23
  .order(:first_name)
23
- },
24
- },
25
- )
24
+ }
25
+ }
26
26
  end
27
27
 
28
28
  it "maps the result of the subset" do
@@ -36,13 +36,15 @@ RSpec.describe "Predefined subset queries" do
36
36
 
37
37
  context "on a has many association" do
38
38
  before do
39
- configs.fetch(:posts).merge!(
40
- subsets: {
41
- body_contains: ->(dataset, search_string) {
39
+ mappings
40
+ .setup_mapping(:posts) { |posts|
41
+ posts.fields([:id, :subject, :body, :created_at, :updated_at])
42
+ posts.has_many(:comments)
43
+ posts.has_many_through(:categories)
44
+ posts.subset(:body_contains) { |dataset, search_string|
42
45
  dataset.where(body: /#{search_string}/)
43
- },
44
- },
45
- )
46
+ }
47
+ }
46
48
  end
47
49
 
48
50
  let(:user) { users.first }