terrestrial 0.3.0 → 0.5.0

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 (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 }