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
@@ -10,7 +10,7 @@ require "support/object_store_setup"
10
10
  require "support/seed_data_setup"
11
11
  require "terrestrial"
12
12
 
13
- RSpec.describe "README examples" do
13
+ RSpec.describe "README examples", backend: "sequel" do
14
14
  readme_contents = File.read("README.md")
15
15
 
16
16
  code_samples = readme_contents
@@ -13,11 +13,17 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
13
13
  let(:user) { user_query.first }
14
14
 
15
15
  context "when loading the root node" do
16
+ before { preload_user_store_to_avoid_counting_startup_queries }
17
+
16
18
  it "only performs one read" do
17
19
  expect {
18
- user
20
+ user_query.first
19
21
  }.to change { query_counter.read_count }.by(1)
20
22
  end
23
+
24
+ def preload_user_store_to_avoid_counting_startup_queries
25
+ user_store
26
+ end
21
27
  end
22
28
 
23
29
  context "when traversing associations (lazily)" do
@@ -89,20 +95,17 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
89
95
  user.email = modified_email
90
96
  end
91
97
 
92
- it "performs 1 update" do
98
+ it "performs 1 write" do
93
99
  expect {
94
100
  user_store.save(user)
95
- }.to change { query_counter.update_count }.by(1)
101
+ }.to change { query_counter.write_count }.by(1)
96
102
  end
97
103
 
98
104
  it "sends only the updated fields to the datastore" do
99
105
  user_store.save(user)
100
- update_sql = query_counter.updates.last
106
+ upsert_sql = query_counter.upserts.last
101
107
 
102
- # TODO: SQL parser?
103
- expect(update_sql).to eq(
104
- %{UPDATE "users" SET "email" = '#{modified_email}' WHERE ("id" = '#{user.id}')}
105
- )
108
+ expect(upsert_sql).not_to include(user.first_name, user.last_name)
106
109
  end
107
110
  end
108
111
  end
@@ -117,7 +120,7 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
117
120
  it "performs 1 update" do
118
121
  expect {
119
122
  user_store.save(user)
120
- }.to change { query_counter.update_count }.by(1)
123
+ }.to change { query_counter.write_count }.by(1)
121
124
  end
122
125
 
123
126
  it "performs 0 deletes" do
@@ -126,7 +129,7 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
126
129
  }.to change { query_counter.delete_count }.by(0)
127
130
  end
128
131
 
129
- it "performs 0 additional reads" do
132
+ xit "performs 0 additional reads" do
130
133
  expect {
131
134
  user_store.save(user)
132
135
  }.to change { query_counter.read_count }.by(0)
@@ -146,7 +149,7 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
146
149
  it "performs 1 write" do
147
150
  expect {
148
151
  user_store.save(user)
149
- }.to change { query_counter.update_count }.by(1)
152
+ }.to change { query_counter.write_count }.by(1)
150
153
  end
151
154
  end
152
155
 
@@ -160,7 +163,7 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
160
163
  it "performs 1 update" do
161
164
  expect {
162
165
  user_store.save(user)
163
- }.to change { query_counter.update_count }.by(1)
166
+ }.to change { query_counter.write_count }.by(1)
164
167
  end
165
168
  end
166
169
 
@@ -175,7 +178,7 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
175
178
  it "performs 2 updates" do
176
179
  expect {
177
180
  user_store.save(user)
178
- }.to change { query_counter.update_count }.by(2)
181
+ }.to change { query_counter.write_count }.by(2)
179
182
  end
180
183
  end
181
184
  end
@@ -191,12 +194,12 @@ RSpec.describe "Sequel query efficiency", backend: "sequel" do
191
194
  it "performs 1 write" do
192
195
  expect {
193
196
  user_store.save(user)
194
- }.to change { query_counter.update_count }.by(1)
197
+ }.to change { query_counter.write_count }.by(1)
195
198
  end
196
199
  end
197
200
 
198
- context "when traversing assocations (eagerly)" do
199
- context "laoding `#all` from the object store with one assocation" do
201
+ context "when traversing association (eagerly)" do
202
+ context "laoding `#all` from the object store with one association" do
200
203
  it "performs 1 read per table rather than n + 1" do
201
204
  expect {
202
205
  user_store.eager_load(:posts => []).all.map { |user|
data/spec/spec_helper.rb CHANGED
@@ -3,6 +3,8 @@ require "support/sequel_test_support"
3
3
  require "support/memory_adapter_test_support"
4
4
  require "support/blog_schema"
5
5
 
6
+ Warning[:deprecated] = false
7
+
6
8
  RSpec.configure do |config|
7
9
  config.expect_with :rspec do |expectations|
8
10
  expectations.include_chain_clauses_in_custom_matcher_descriptions = true
@@ -44,7 +46,10 @@ RSpec.configure do |config|
44
46
  end
45
47
 
46
48
  RSpec.shared_context "adapter setup" do
47
- let(:datastore) { adapter_support.build_datastore(schema) }
49
+ define_method(:datastore) do
50
+ @datastore ||= adapter_support.build_datastore(schema)
51
+ end
52
+
48
53
  let(:query_counter) { adapter_support.query_counter }
49
54
  end
50
55
 
@@ -12,6 +12,7 @@ BLOG_SCHEMA = {
12
12
  { name: :body, type: String },
13
13
  { name: :author_id, type: String},
14
14
  { name: :created_at, type: DateTime },
15
+ { name: :updated_at, type: DateTime },
15
16
  ],
16
17
  comments: [
17
18
  { name: :id, type: String, options: { primary_key: true } },
@@ -24,15 +25,18 @@ BLOG_SCHEMA = {
24
25
  { name: :name, type: String },
25
26
  ],
26
27
  categories_to_posts: [
27
- { name: :post_id, type: String },
28
- { name: :category_id, type: String },
28
+ { name: :post_id, type: String, options: { null: false } },
29
+ { name: :category_id, type: String, options: { null: false } },
29
30
  ],
30
31
  },
32
+ unique_indexes: [
33
+ # [:categories_to_posts, :post_id, :category_id]
34
+ ],
31
35
  foreign_keys: [
32
36
  [:posts, :author_id, :users, :id],
33
37
  [:comments, :post_id, :posts, :id],
34
38
  [:comments, :commenter_id, :users, :id],
35
- [:categories_to_posts, :post_id, :posts, :id],
39
+ [:categories_to_posts, :post_id, :posts, :id, on_delete: :cascade],
36
40
  [:categories_to_posts, :category_id, :categories, :id],
37
41
  ]
38
42
  }
@@ -8,6 +8,14 @@ RSpec.shared_context "object graph setup" do
8
8
  end
9
9
 
10
10
  def setup_circular_references_avoiding_stack_overflow
11
+ biscuits_post.author = hansel
12
+ sleep_post.author = hansel
13
+
14
+ hansel.posts = [
15
+ biscuits_post,
16
+ sleep_post,
17
+ ]
18
+
11
19
  biscuits_post_comment.commenter = hansel
12
20
  cat_biscuits_category.posts = [ biscuits_post ]
13
21
  end
@@ -26,9 +34,16 @@ RSpec.shared_context "object graph setup" do
26
34
  end
27
35
 
28
36
  def initialize(attrs)
29
- members.sort == attrs.keys.sort or (
30
- raise(ArgumentError.new("Expected `#{self.class.members}` got `#{attrs.keys}"))
31
- )
37
+ if members.sort != attrs.keys.sort
38
+ missing = (members - attrs.keys).sort
39
+ unexpected = (attrs.keys - members).sort
40
+ raise(ArgumentError.new(
41
+ "#{self.class} initialized with incorrect arguments." \
42
+ "Missing: #{missing}. " \
43
+ "Unexpected: #{unexpected}. " \
44
+ "Received: #{attrs.keys}."
45
+ ))
46
+ end
32
47
 
33
48
  members.each { |member| send("#{member}=", attrs.fetch(member)) }
34
49
  end
@@ -43,51 +58,24 @@ RSpec.shared_context "object graph setup" do
43
58
  end
44
59
 
45
60
  User ||= PlainObject.with_members(:id, :first_name, :last_name, :email, :posts)
46
- Post ||= PlainObject.with_members(:id, :subject, :body, :comments, :categories, :created_at)
61
+ Post ||= PlainObject.with_members(:id, :author, :subject, :body, :comments, :categories, :created_at, :updated_at)
47
62
  Comment ||= PlainObject.with_members(:id, :commenter, :body)
48
63
  Category ||= PlainObject.with_members(:id, :name, :posts)
49
64
 
50
- let(:factories) {
51
- {
52
- users: User.method(:new),
53
- posts: Post.method(:new),
54
- comments: Comment.method(:new),
55
- categories: Category.method(:new),
56
- categories_to_posts: ->(x){x},
57
- noop: ->(x){x},
58
- }
59
- }
60
-
61
- let(:default_serializer) {
62
- ->(fields) {
63
- ->(object) {
64
- Terrestrial::Serializer.new(fields, object).to_h
65
- }
66
- }
67
- }
68
-
69
- let(:null_serializer) {
70
- ->(_fields) {
71
- ->(x){x}
72
- }
73
- }
74
-
75
65
  let(:hansel) {
76
- factories.fetch(:users).call(
66
+ User.new(
77
67
  id: "users/1",
78
68
  first_name: "Hansel",
79
69
  last_name: "Trickett",
80
70
  email: "hansel@tricketts.org",
81
- posts: [
82
- biscuits_post,
83
- sleep_post,
84
- ],
71
+ posts: [],
85
72
  )
86
73
  }
87
74
 
88
75
  let(:biscuits_post) {
89
- factories.fetch(:posts).call(
76
+ Post.new(
90
77
  id: "posts/1",
78
+ author: nil,
91
79
  subject: "Biscuits",
92
80
  body: "I like them",
93
81
  comments: [
@@ -97,12 +85,14 @@ RSpec.shared_context "object graph setup" do
97
85
  cat_biscuits_category,
98
86
  ],
99
87
  created_at: Time.parse("2015-09-05T15:00:00+01:00"),
88
+ updated_at: Time.parse("2015-09-05T15:00:00+01:00"),
100
89
  )
101
90
  }
102
91
 
103
92
  let(:sleep_post) {
104
- factories.fetch(:posts).call(
93
+ Post.new(
105
94
  id: "posts/2",
95
+ author: nil,
106
96
  subject: "Sleeping",
107
97
  body: "I do it three times purrr day",
108
98
  comments: [],
@@ -110,11 +100,12 @@ RSpec.shared_context "object graph setup" do
110
100
  chilling_category,
111
101
  ],
112
102
  created_at: Time.parse("2015-09-02T15:00:00+01:00"),
103
+ updated_at: Time.parse("2015-09-02T15:00:00+01:00"),
113
104
  )
114
105
  }
115
106
 
116
107
  let(:biscuits_post_comment) {
117
- factories.fetch(:comments).call(
108
+ Comment.new(
118
109
  id: "comments/1",
119
110
  body: "oh noes",
120
111
  commenter: nil,
@@ -122,7 +113,7 @@ RSpec.shared_context "object graph setup" do
122
113
  }
123
114
 
124
115
  let(:cat_biscuits_category) {
125
- factories.fetch(:categories).call(
116
+ Category.new(
126
117
  id: "categories/1",
127
118
  name: "Cat biscuits",
128
119
  posts: [],
@@ -130,7 +121,7 @@ RSpec.shared_context "object graph setup" do
130
121
  }
131
122
 
132
123
  let(:chilling_category) {
133
- factories.fetch(:categories).call(
124
+ Category.new(
134
125
  id: "categories/2",
135
126
  name: "Chillaxing",
136
127
  posts: [],
@@ -15,207 +15,27 @@ require "support/object_graph_setup"
15
15
  RSpec.shared_context "object store setup" do
16
16
  include_context "object graph setup"
17
17
 
18
+ let(:user_store) { object_store[:users] }
19
+
18
20
  let(:object_store) {
19
- Terrestrial.object_store(mappings: mappings, datastore: datastore)
21
+ Terrestrial.object_store(config: mappings)
20
22
  }
21
23
 
22
- let(:user_store) { object_store[:users] }
23
-
24
24
  let(:mappings) {
25
- Hash[
26
- configs.map { |name, config|
27
- fields = config.fetch(:fields) + config.fetch(:associations).keys
28
-
29
- associations = config.fetch(:associations).map { |assoc_name, assoc_config|
30
- [
31
- assoc_name,
32
- case assoc_config.fetch(:type)
33
- when :one_to_many
34
- Terrestrial::OneToManyAssociation.new(
35
- **assoc_defaults.merge(
36
- assoc_config.dup.tap { |h| h.delete(:type) }
37
- )
38
- )
39
- when :many_to_one
40
- Terrestrial::ManyToOneAssociation.new(
41
- assoc_config.dup.tap { |h| h.delete(:type) }
42
- )
43
- when :many_to_many
44
- Terrestrial::ManyToManyAssociation.new(
45
- **assoc_defaults
46
- .merge(
47
- join_mapping_name: assoc_config.fetch(:join_mapping_name),
48
- )
49
- .merge(
50
- assoc_config.dup.tap { |h|
51
- h.delete(:type)
52
- h.delete(:join_namespace)
53
- }
54
- )
55
- )
56
- else
57
- raise "Association type not supported"
58
- end
59
- ]
60
- }
61
-
62
- [
63
- name,
64
- Terrestrial::RelationMapping.new(
65
- name: name,
66
- namespace: config.fetch(:namespace),
67
- fields: config.fetch(:fields),
68
- primary_key: config.fetch(:primary_key),
69
- serializer: serializers.fetch(config.fetch(:serializer)).call(fields),
70
- associations: Hash[associations],
71
- factory: factories.fetch(name),
72
- subsets: Terrestrial::SubsetQueriesProxy.new(config.fetch(:subsets, {}))
73
- )
74
- ]
25
+ Terrestrial.config(datastore)
26
+ .setup_mapping(:users) { |users|
27
+ users.has_many(:posts, foreign_key: :author_id)
75
28
  }
76
- ]
77
- }
78
-
79
- def assoc_defaults
80
- {
81
- order: Terrestrial::QueryOrder.new(fields: [], direction: "ASC")
82
- }
83
- end
84
-
85
- let(:has_many_proxy_factory) {
86
- ->(query:, loader:, mapping_name:) {
87
- Terrestrial::CollectionMutabilityProxy.new(
88
- Terrestrial::LazyCollection.new(
89
- query,
90
- loader,
91
- mappings.fetch(mapping_name).subsets,
92
- )
93
- )
94
- }
95
- }
96
-
97
- let(:many_to_one_proxy_factory) {
98
- ->(query:, loader:, preloaded_data:) {
99
- Terrestrial::LazyObjectProxy.new(
100
- ->{ loader.call(query.first) },
101
- preloaded_data,
102
- )
103
- }
104
- }
105
-
106
- let(:serializers) {
107
- {
108
- default: default_serializer,
109
- null: null_serializer,
110
- }
111
- }
112
-
113
- let(:configs) {
114
- {
115
- users: {
116
- namespace: :users,
117
- primary_key: [:id],
118
- fields: [
119
- :id,
120
- :first_name,
121
- :last_name,
122
- :email,
123
- ],
124
- factory: :user,
125
- serializer: :default,
126
- associations: {
127
- posts: {
128
- type: :one_to_many,
129
- mapping_name: :posts,
130
- foreign_key: :author_id,
131
- key: :id,
132
- proxy_factory: has_many_proxy_factory,
133
- }
134
- },
135
- },
136
-
137
- posts: {
138
- namespace: :posts,
139
- primary_key: [:id],
140
- fields: [
141
- :id,
142
- :subject,
143
- :body,
144
- :created_at,
145
- ],
146
- factory: :post,
147
- serializer: :default,
148
- associations: {
149
- comments: {
150
- type: :one_to_many,
151
- mapping_name: :comments,
152
- foreign_key: :post_id,
153
- key: :id,
154
- proxy_factory: has_many_proxy_factory,
155
- },
156
- categories: {
157
- type: :many_to_many,
158
- mapping_name: :categories,
159
- key: :id,
160
- foreign_key: :post_id,
161
- association_foreign_key: :category_id,
162
- association_key: :id,
163
- join_mapping_name: :categories_to_posts,
164
- proxy_factory: has_many_proxy_factory,
165
- },
166
- },
167
- },
168
-
169
- comments: {
170
- namespace: :comments,
171
- primary_key: [:id],
172
- fields: [
173
- :id,
174
- :body,
175
- ],
176
- factory: :comment,
177
- serializer: :default,
178
- associations: {
179
- commenter: {
180
- type: :many_to_one,
181
- mapping_name: :users,
182
- key: :id,
183
- foreign_key: :commenter_id,
184
- proxy_factory: many_to_one_proxy_factory,
185
- },
186
- },
187
- },
188
-
189
- categories: {
190
- namespace: :categories,
191
- primary_key: [:id],
192
- fields: [
193
- :id,
194
- :name,
195
- ],
196
- factory: :comment,
197
- serializer: :default,
198
- associations: {
199
- posts: {
200
- type: :many_to_many,
201
- mapping_name: :posts,
202
- key: :id,
203
- foreign_key: :category_id,
204
- association_foreign_key: :post_id,
205
- association_key: :id,
206
- join_mapping_name: :categories_to_posts,
207
- proxy_factory: has_many_proxy_factory,
208
- },
209
- },
210
- },
211
-
212
- categories_to_posts: {
213
- namespace: :categories_to_posts,
214
- primary_key: [:category_id, :post_id],
215
- fields: [],
216
- serializer: :null,
217
- associations: {},
29
+ .setup_mapping(:posts) { |posts|
30
+ posts.belongs_to(:author, mapping_name: :users)
31
+ posts.has_many(:comments)
32
+ posts.has_many_through(:categories)
33
+ }
34
+ .setup_mapping(:comments) { |comments|
35
+ comments.belongs_to(:commenter, mapping_name: :users)
36
+ }
37
+ .setup_mapping(:categories) { |categories|
38
+ categories.has_many_through(:posts)
218
39
  }
219
- }
220
40
  }
221
41
  end