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
@@ -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