terrestrial 0.3.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.ruby-version +1 -1
- data/Gemfile.lock +44 -53
- data/README.md +3 -6
- data/bin/test +1 -1
- data/features/env.rb +12 -2
- data/features/example.feature +23 -26
- data/lib/terrestrial.rb +31 -0
- data/lib/terrestrial/adapters/abstract_adapter.rb +6 -0
- data/lib/terrestrial/adapters/memory_adapter.rb +82 -6
- data/lib/terrestrial/adapters/sequel_postgres_adapter.rb +191 -0
- data/lib/terrestrial/configurations/conventional_association_configuration.rb +65 -35
- data/lib/terrestrial/configurations/conventional_configuration.rb +280 -124
- data/lib/terrestrial/configurations/mapping_config_options_proxy.rb +97 -0
- data/lib/terrestrial/deleted_record.rb +12 -8
- data/lib/terrestrial/dirty_map.rb +17 -9
- data/lib/terrestrial/functional_pipeline.rb +64 -0
- data/lib/terrestrial/inspection_string.rb +6 -1
- data/lib/terrestrial/lazy_object_proxy.rb +1 -0
- data/lib/terrestrial/many_to_many_association.rb +34 -20
- data/lib/terrestrial/many_to_one_association.rb +11 -3
- data/lib/terrestrial/one_to_many_association.rb +9 -0
- data/lib/terrestrial/public_conveniencies.rb +65 -82
- data/lib/terrestrial/record.rb +106 -0
- data/lib/terrestrial/relation_mapping.rb +43 -12
- data/lib/terrestrial/relational_store.rb +33 -11
- data/lib/terrestrial/upsert_record.rb +54 -0
- data/lib/terrestrial/version.rb +1 -1
- data/spec/automatic_timestamps_spec.rb +339 -0
- data/spec/changes_api_spec.rb +81 -0
- data/spec/config_override_spec.rb +28 -19
- data/spec/custom_serializers_spec.rb +3 -2
- data/spec/database_default_fields_spec.rb +213 -0
- data/spec/database_generated_id_spec.rb +291 -0
- data/spec/database_owned_fields_and_timestamps_spec.rb +200 -0
- data/spec/deletion_spec.rb +1 -1
- data/spec/error_handling/factory_error_handling_spec.rb +1 -4
- data/spec/error_handling/serialization_error_spec.rb +1 -4
- data/spec/error_handling/upsert_error_spec.rb +7 -11
- data/spec/graph_persistence_spec.rb +52 -18
- data/spec/ordered_association_spec.rb +10 -12
- data/spec/predefined_queries_spec.rb +14 -12
- data/spec/readme_examples_spec.rb +1 -1
- data/spec/sequel_query_efficiency_spec.rb +19 -16
- data/spec/spec_helper.rb +6 -1
- data/spec/support/blog_schema.rb +7 -3
- data/spec/support/object_graph_setup.rb +30 -39
- data/spec/support/object_store_setup.rb +16 -196
- data/spec/support/seed_data_setup.rb +15 -149
- data/spec/support/seed_records.rb +141 -0
- data/spec/support/sequel_test_support.rb +46 -13
- data/spec/terrestrial/abstract_record_spec.rb +138 -106
- data/spec/terrestrial/adapters/sequel_postgres_adapter_spec.rb +138 -0
- data/spec/terrestrial/deleted_record_spec.rb +0 -27
- data/spec/terrestrial/dirty_map_spec.rb +52 -77
- data/spec/terrestrial/functional_pipeline_spec.rb +153 -0
- data/spec/terrestrial/inspection_string_spec.rb +61 -0
- data/spec/terrestrial/upsert_record_spec.rb +29 -0
- data/terrestrial.gemspec +7 -8
- metadata +43 -40
- data/MissingFeatures.md +0 -64
- data/lib/terrestrial/abstract_record.rb +0 -99
- data/lib/terrestrial/association_loaders.rb +0 -52
- data/lib/terrestrial/upserted_record.rb +0 -15
- data/spec/terrestrial/public_conveniencies_spec.rb +0 -63
- 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
|
data/spec/deletion_spec.rb
CHANGED
@@ -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
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
-
|
35
|
-
|
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 "
|
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:
|
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
|
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
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
34
|
-
|
35
|
-
fields:
|
36
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
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 }
|