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,106 @@
1
+ require "forwardable"
2
+ require "set"
3
+
4
+ module Terrestrial
5
+ class Record
6
+ extend Forwardable
7
+
8
+ def initialize(mapping, attributes)
9
+ @mapping = mapping
10
+ @attributes = attributes
11
+ end
12
+
13
+ attr_reader :mapping, :attributes
14
+ def_delegators :to_h, :fetch
15
+
16
+ def namespace
17
+ mapping.namespace
18
+ end
19
+
20
+ def if_upsert(&block)
21
+ self
22
+ end
23
+
24
+ def if_delete(&block)
25
+ self
26
+ end
27
+
28
+ def updatable?
29
+ updatable_attributes.any?
30
+ end
31
+
32
+ def updatable_attributes
33
+ attributes.reject { |k, _v| non_updatable_fields.include?(k) }
34
+ end
35
+
36
+ def keys
37
+ attributes.keys
38
+ end
39
+
40
+ def identity_values
41
+ identity.values
42
+ end
43
+
44
+ def identity
45
+ attributes.select { |k,_v| identity_fields.include?(k) }
46
+ end
47
+
48
+ def identity_fields
49
+ mapping.primary_key
50
+ end
51
+
52
+ def merge(more_attributes)
53
+ new_with_attributes(attributes.merge(more_attributes))
54
+ end
55
+
56
+ def merge!(more_attributes)
57
+ attributes.merge!(more_attributes)
58
+ end
59
+
60
+ def reject(&block)
61
+ new_with_attributes(updatable_attributes.reject(&block).merge(identity))
62
+ end
63
+
64
+ def to_h
65
+ attributes.to_h
66
+ end
67
+
68
+ def empty?
69
+ updatable_attributes.empty?
70
+ end
71
+
72
+ def subset?(other_record)
73
+ mapping == other_record.mapping &&
74
+ to_set.subset?(other_record.to_set)
75
+ end
76
+
77
+ def deep_clone
78
+ new_with_attributes(Marshal.load(Marshal.dump(attributes)))
79
+ end
80
+
81
+ def ==(other)
82
+ other.is_a?(self.class) &&
83
+ [other.mapping, other.attributes] == [mapping, attributes]
84
+ end
85
+
86
+ protected
87
+
88
+ def to_set
89
+ Set.new(attributes.to_a)
90
+ end
91
+
92
+ private
93
+
94
+ def non_updatable_fields
95
+ identity_fields + mapping.database_owned_fields + nil_fields_expecting_default_value
96
+ end
97
+
98
+ def nil_fields_expecting_default_value
99
+ mapping.database_default_fields.select { |k| attributes[k].nil? }
100
+ end
101
+
102
+ def new_with_attributes(new_attributes)
103
+ self.class.new(mapping, new_attributes)
104
+ end
105
+ end
106
+ end
@@ -1,29 +1,38 @@
1
1
  require "terrestrial/error"
2
- require "terrestrial/upserted_record"
2
+ require "terrestrial/upsert_record"
3
3
  require "terrestrial/deleted_record"
4
4
 
5
5
  module Terrestrial
6
6
  class RelationMapping
7
- def initialize(name:, namespace:, fields:, primary_key:, factory:, serializer:, associations:, subsets:)
7
+ def initialize(name:, namespace:, fields:, database_owned_fields:, database_default_fields:, primary_key:, factory:, serializer:, associations:, subsets:, observers:)
8
8
  @name = name
9
9
  @namespace = namespace
10
10
  @fields = fields
11
+ @database_owned_fields = database_owned_fields
12
+ @database_default_fields = database_default_fields
11
13
  @primary_key = primary_key
12
14
  @factory = factory
13
15
  @serializer = serializer
14
16
  @associations = associations
15
17
  @subsets = subsets
18
+ @observers = observers
19
+
20
+ @incoming_foreign_keys = []
16
21
  end
17
22
 
18
- attr_reader :name, :namespace, :fields, :primary_key, :factory, :serializer, :associations, :subsets
19
- private :factory, :serializer
23
+ attr_reader :name, :namespace, :fields, :database_owned_fields, :database_default_fields, :primary_key, :factory, :serializer, :associations, :subsets, :created_at_field, :updated_at_field, :observers
24
+ private :factory, :serializer, :observers
20
25
 
21
26
  def add_association(name, new_association)
22
27
  @associations = associations.merge(name => new_association)
23
28
  end
24
29
 
30
+ def register_foreign_key(fk)
31
+ @incoming_foreign_keys += fk
32
+ end
33
+
25
34
  def load(record)
26
- factory.call(record)
35
+ factory.call(reject_non_factory_fields(record))
27
36
  rescue => e
28
37
  raise LoadError.new(namespace, factory, record, e)
29
38
  end
@@ -31,8 +40,11 @@ module Terrestrial
31
40
  def serialize(object, depth, foreign_keys = {})
32
41
  object_attributes = serializer.call(object)
33
42
 
43
+ record = upsertable_record(object, object_attributes, depth, foreign_keys)
44
+ observers.each { |o| o.post_serialize(self, object, record) }
45
+
34
46
  [
35
- record(object_attributes, depth, foreign_keys),
47
+ record,
36
48
  extract_associations(object_attributes)
37
49
  ]
38
50
  rescue => e
@@ -45,12 +57,20 @@ module Terrestrial
45
57
  [deleted_record(object_attributes, depth)]
46
58
  end
47
59
 
60
+ def post_save(object, record, new_attributes)
61
+ new_record = upsertable_record(object, new_attributes, 0, {})
62
+
63
+ observers.each { |o| o.post_save(self, object, record, new_record) }
64
+
65
+ record.merge!(new_attributes)
66
+ end
67
+
48
68
  private
49
69
 
50
- def record(attributes, depth, foreign_keys)
51
- UpsertedRecord.new(
52
- namespace,
53
- primary_key,
70
+ def upsertable_record(object, attributes, depth, foreign_keys)
71
+ UpsertRecord.new(
72
+ self,
73
+ object,
54
74
  select_mapped_fields(attributes).merge(foreign_keys),
55
75
  depth,
56
76
  )
@@ -58,8 +78,7 @@ module Terrestrial
58
78
 
59
79
  def deleted_record(attributes, depth)
60
80
  DeletedRecord.new(
61
- namespace,
62
- primary_key,
81
+ self,
63
82
  attributes,
64
83
  depth,
65
84
  )
@@ -76,5 +95,17 @@ module Terrestrial
76
95
  def select_mapped_fields(attributes)
77
96
  attributes.select { |name, _value| fields.include?(name) }
78
97
  end
98
+
99
+ def reject_non_factory_fields(attributes)
100
+ attributes.reject { |name, _value| (@incoming_foreign_keys + local_foreign_keys).include?(name) }
101
+ end
102
+
103
+ def factory_fields
104
+ @factory_fields ||= fields - (local_foreign_keys + @incoming_foreign_keys)
105
+ end
106
+
107
+ def local_foreign_keys
108
+ @local_foreign_keys ||= associations.values.flat_map(&:local_foreign_keys)
109
+ end
79
110
  end
80
111
  end
@@ -7,7 +7,7 @@ module Terrestrial
7
7
  include Enumerable
8
8
  include InspectionString
9
9
 
10
- def initialize(mappings:, mapping_name:, datastore:, dataset:, load_pipeline:, dump_pipeline:)
10
+ def initialize(mappings:, mapping_name:, datastore:, load_pipeline:, dump_pipeline:, dataset: nil)
11
11
  @mappings = mappings
12
12
  @mapping_name = mapping_name
13
13
  @datastore = datastore
@@ -17,17 +17,33 @@ module Terrestrial
17
17
  @eager_data = {}
18
18
  end
19
19
 
20
- attr_reader :mappings, :mapping_name, :datastore, :dataset, :load_pipeline, :dump_pipeline
21
- private :mappings, :mapping_name, :datastore, :dataset, :load_pipeline, :dump_pipeline
20
+ attr_reader :mappings, :mapping_name, :datastore, :load_pipeline, :dump_pipeline
21
+ private :mappings, :mapping_name, :datastore, :load_pipeline, :dump_pipeline
22
22
 
23
23
  def save(graph)
24
- record_dump = graph_serializer.call(mapping_name, graph)
24
+ record_dump = serialize_graph(graph)
25
25
 
26
26
  dump_pipeline.call(record_dump)
27
27
 
28
28
  self
29
29
  end
30
30
 
31
+ def changes_sql(graph)
32
+ changes(graph).map { |record|
33
+ datastore.changes_sql(record)
34
+ }
35
+ end
36
+
37
+ def changes(graph)
38
+ changes, _ = dump_pipeline
39
+ .take_until(:remove_unchanged_fields)
40
+ .call(
41
+ serialize_graph(graph)
42
+ )
43
+
44
+ changes
45
+ end
46
+
31
47
  def all
32
48
  self
33
49
  end
@@ -58,22 +74,20 @@ module Terrestrial
58
74
  self
59
75
  end
60
76
 
61
- def delete(object, cascade: false)
77
+ def delete(object)
62
78
  dump_pipeline.call(
63
- graph_serializer.call(mapping_name, object)
79
+ serialize_graph(object)
64
80
  .select { |record| record.depth == 0 }
65
81
  .reverse
66
82
  .take(1)
67
- .map { |record|
68
- DeletedRecord.new(record.namespace, record.identity)
69
- }
83
+ .map { |record| DeletedRecord.new(mapping, record.attributes, 0) }
70
84
  )
71
85
  end
72
86
 
73
87
  private
74
88
 
75
- def mapping
76
- mappings.fetch(mapping_name)
89
+ def serialize_graph(graph)
90
+ graph_serializer.call(mapping_name, graph)
77
91
  end
78
92
 
79
93
  def eager_load_associations(mapping, parent_dataset, association_name_map)
@@ -136,6 +150,14 @@ module Terrestrial
136
150
  )
137
151
  end
138
152
 
153
+ def dataset
154
+ @dataset ||= datastore[mapping.namespace]
155
+ end
156
+
157
+ def mapping
158
+ mappings.fetch(mapping_name)
159
+ end
160
+
139
161
  def inspectable_properties
140
162
  [
141
163
  :mapping_name,
@@ -0,0 +1,54 @@
1
+ require "terrestrial/record"
2
+
3
+ module Terrestrial
4
+ class UpsertRecord < Record
5
+ def initialize(mapping, object, attributes, depth)
6
+ @mapping = mapping
7
+ @object = object
8
+ @attributes = attributes
9
+ @depth = depth
10
+ end
11
+
12
+ attr_reader :mapping, :object, :attributes, :depth
13
+
14
+ def id?
15
+ identity_values.reject(&:nil?).any?
16
+ end
17
+
18
+ def set_id(id)
19
+ raise "Cannot use #set_id with composite key" if identity_fields.length > 1
20
+ merge!(identity_fields[0] => id)
21
+ end
22
+
23
+ def get(name)
24
+ fetch(name)
25
+ end
26
+
27
+ def set(name, value)
28
+ merge!(name => value)
29
+ end
30
+
31
+ def if_upsert(&block)
32
+ block.call(self)
33
+ self
34
+ end
35
+
36
+ def on_upsert(new_attributes)
37
+ mapping.post_save(object, self, new_attributes)
38
+ end
39
+
40
+ def insertable
41
+ to_h.reject { |k, v| v.nil? && identity_fields.include?(k) }
42
+ end
43
+
44
+ def include?(field_name)
45
+ keys.include?(field_name)
46
+ end
47
+
48
+ private
49
+
50
+ def new_with_attributes(new_attributes)
51
+ self.class.new(mapping, object, new_attributes, depth)
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module Terrestrial
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -0,0 +1,339 @@
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
+ RSpec.describe "Automatic timestamps", backend: "sequel" do
8
+ include_context "object store setup"
9
+
10
+ before(:all) do
11
+ create_db_timestamp_tables
12
+ end
13
+
14
+ after(:all) do
15
+ drop_db_timestamp_tables
16
+ end
17
+
18
+ before(:each) do
19
+ clean_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_auto_timestamps_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
+ updated_at: nil,
49
+ created_at: nil,
50
+ )
51
+ }
52
+
53
+ let(:clock) {
54
+ StaticClock.new(Time.parse("2020-04-20T17:00:00 UTC"))
55
+ }
56
+
57
+ let(:with_auto_timestamps_config) {
58
+ Terrestrial.config(datastore, clock: clock)
59
+ .setup_mapping(:users) { |users|
60
+ users.has_many(:posts, foreign_key: :author_id)
61
+ }
62
+ .setup_mapping(:posts) { |posts|
63
+ posts.relation_name(:timestamped_posts)
64
+ posts.created_at_timestamp
65
+ posts.updated_at_timestamp
66
+ }
67
+ }
68
+
69
+ context "new objects" do
70
+ it "adds the current time to the timestamp fields" do
71
+ expected_timestamp = clock.now.utc
72
+
73
+ user_store.save(user_with_post)
74
+
75
+ expect(datastore).to have_persisted(
76
+ :timestamped_posts,
77
+ hash_including(
78
+ created_at: expected_timestamp,
79
+ updated_at: expected_timestamp,
80
+ )
81
+ )
82
+ end
83
+
84
+ it "updates the objects with the new timestamp values" do
85
+ expect(post).to receive(:created_at=).with(clock.now)
86
+ expect(post).to receive(:updated_at=).with(clock.now)
87
+
88
+ user_store.save(user_with_post)
89
+ end
90
+ end
91
+
92
+ context "after an initial successful save of the object graph" do
93
+ before do
94
+ user_store.save(user_with_post)
95
+ end
96
+
97
+ context "if the clock has not yet advanced" do
98
+ context "when saving again without modifications" do
99
+ it "does not perform any more database writes" do
100
+ expect {
101
+ user_store.save(user_with_post)
102
+ }.not_to change { query_counter.write_count }
103
+ end
104
+
105
+ it "does not produce any change records" do
106
+ expect(user_store.changes(user_with_post)).to be_empty
107
+ end
108
+ end
109
+ end
110
+
111
+ context "when saving modifications and the clock has advanced" do
112
+ before do
113
+ @created_at_time = clock.now
114
+ clock.tick
115
+ end
116
+ let(:created_at_time) { @created_at_time }
117
+
118
+ it "persists the updated_at field at the current time" do
119
+ current_time = clock.now
120
+ post.body = post.body + " edited"
121
+
122
+ user_store.save(user_with_post)
123
+
124
+ expect(datastore).to have_persisted(
125
+ :timestamped_posts,
126
+ hash_including(
127
+ id: post.id,
128
+ body: post.body,
129
+ updated_at: current_time,
130
+ )
131
+ )
132
+ end
133
+
134
+ it "updates the object's updated_at field to the current time" do
135
+ current_time = clock.now
136
+ post.body = post.body + " edited"
137
+
138
+ expect(post).to receive(:updated_at=).with(current_time)
139
+
140
+ user_store.save(user_with_post)
141
+ end
142
+
143
+ it "does not change the created_at time" do
144
+ post.body = post.body + " edited"
145
+
146
+ user_store.save(user_with_post)
147
+
148
+ expect(datastore).to have_persisted(
149
+ :timestamped_posts,
150
+ hash_including(
151
+ id: post.id,
152
+ created_at: created_at_time,
153
+ )
154
+ )
155
+ end
156
+ end
157
+ end
158
+
159
+ context "user modifies a the created_at field" do
160
+ it "persists the user's value" do
161
+ party_time = Time.parse("1999-01-01t00:00:00 utc")
162
+ post.created_at = party_time
163
+ user_store.save(user_with_post)
164
+
165
+ expect(datastore).to have_persisted(
166
+ :timestamped_posts,
167
+ hash_including(
168
+ id: post.id,
169
+ created_at: party_time,
170
+ )
171
+ )
172
+ end
173
+ end
174
+
175
+ context "with user-defined timestamp callbacks" do
176
+ before do
177
+ post = user_with_post.posts.first
178
+ change_objects_timestamp_setter_methods(post)
179
+ end
180
+
181
+ let(:with_auto_timestamps_config) {
182
+ Terrestrial.config(datastore, clock: clock)
183
+ .setup_mapping(:users) { |users|
184
+ users.has_many(:posts, foreign_key: :author_id)
185
+ }
186
+ .setup_mapping(:posts) { |posts|
187
+ posts.relation_name(:timestamped_posts)
188
+ posts.created_at_timestamp { |object, timestamp|
189
+ object.unconventional_created_at = timestamp
190
+ }
191
+ posts.updated_at_timestamp { |object, timestamp|
192
+ object.unconventional_updated_at = timestamp
193
+ }
194
+ }
195
+ }
196
+
197
+ it "sets the timestamps via the callbacks" do
198
+ post = user_with_post.posts.first
199
+
200
+ user_store.save(user_with_post)
201
+
202
+ expect(post.created_at).to eq(clock.now)
203
+ expect(post.updated_at).to eq(clock.now)
204
+ end
205
+
206
+ xcontext "if there's an error in the callback" do
207
+ before do
208
+ post = user_with_post.posts.first
209
+
210
+ def post.unconventional_updated_at=(time)
211
+ raise "Original error message"
212
+ end
213
+ end
214
+
215
+ it "is caught, wrapped and re-raised" do
216
+ expect {
217
+ user_store.save(user_with_post)
218
+ }.to raise_error(
219
+ "Error running user-defined setter function defined in Terrestrial mapping lib/spec/automatic_timestamps_spec.rb:183.\n" +
220
+ "Got Error: Original error message"
221
+ )
222
+ end
223
+
224
+ it "raises an error which has a backtrace pointing to where the callback is invoked" do
225
+ begin
226
+ user_store.save(user_with_post)
227
+ rescue => e
228
+ ensure
229
+ unless e
230
+ raise "Failed to intentionally raise error in code under test"
231
+ end
232
+
233
+ puts filtered_backtrace = filter_library_code_from_backtrace(e.backtrace)
234
+
235
+ actual_setter_location = /#{__FILE__}:[0-9]+:in .unconventional_created_at=/
236
+
237
+ expected_files_and_methods = [
238
+ actual_setter_location,
239
+ /time_stamp_observer\.rb:[0-9]+:in .post_save/,
240
+ /relation_mapping\.rb:[0-9]+:in .post_save/,
241
+ /upsert_record\.rb:[0-9]+:in .on_upsert/,
242
+ /upsert_record\.rb:[0-9]+:in .if_upsert/,
243
+ ]
244
+
245
+ aggregate_failures do
246
+ expected_files_and_methods.each do |pattern|
247
+ # TODO: Seems like this should be possible with an RSpec machter for a better failure message
248
+ expect(filtered_backtrace.any? { |l| pattern.match(l) }).to be true
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ def filter_library_code_from_backtrace(backtrace)
255
+ backtrace
256
+ .reject { |l| l.include?("lib/rspec") }
257
+ .reject { |l| l.include?("lib/bundler") }
258
+ .reject { |l| l.include?("lib/sequel") }
259
+ end
260
+ end
261
+
262
+ def change_objects_timestamp_setter_methods(post)
263
+ def post.created_at=(_)
264
+ raise "Should not be called"
265
+ end
266
+ def post.updated_at=(_)
267
+ raise "Should not be called"
268
+ end
269
+ def post.unconventional_created_at=(time)
270
+ @created_at = time
271
+ end
272
+ def post.unconventional_updated_at=(time)
273
+ @updated_at = time
274
+ end
275
+ end
276
+ end
277
+
278
+ def schema
279
+ {
280
+ :tables => {
281
+ :timestamped_posts => [
282
+ {
283
+ :name => :id,
284
+ :type => String,
285
+ :options => {
286
+ :primary_key => true,
287
+ }
288
+ },
289
+ {
290
+ :name => :subject,
291
+ :type => String,
292
+ },
293
+ {
294
+ :name => :body,
295
+ :type => String,
296
+ },
297
+ {
298
+ :name => :author_id,
299
+ :type => String,
300
+ },
301
+ {
302
+ :name => :created_at,
303
+ :type => DateTime,
304
+ },
305
+ {
306
+ :name => :updated_at,
307
+ :type => DateTime,
308
+ },
309
+ ],
310
+ },
311
+ }
312
+ end
313
+
314
+ def create_db_timestamp_tables
315
+ Terrestrial::SequelTestSupport.create_tables(schema.fetch(:tables))
316
+ end
317
+
318
+ def drop_db_timestamp_tables
319
+ Terrestrial::SequelTestSupport.drop_tables(schema.fetch(:tables).keys)
320
+ end
321
+
322
+ def clean_db_timestamp_tables
323
+ Terrestrial::SequelTestSupport.clean_tables(schema.fetch(:tables).keys)
324
+ end
325
+
326
+ class StaticClock
327
+ def initialize(time)
328
+ @time = time
329
+ end
330
+
331
+ def now
332
+ @time
333
+ end
334
+
335
+ def tick
336
+ @time += 1
337
+ end
338
+ end
339
+ end