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