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,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/
|
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
|
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
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
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:,
|
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, :
|
21
|
-
private :mappings, :mapping_name, :datastore, :
|
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 =
|
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
|
77
|
+
def delete(object)
|
62
78
|
dump_pipeline.call(
|
63
|
-
|
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
|
76
|
-
|
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
|
data/lib/terrestrial/version.rb
CHANGED
@@ -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
|