terrestrial 0.1.0 → 0.1.1

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -9
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +28 -0
  6. data/Gemfile.lock +73 -0
  7. data/LICENSE.txt +22 -0
  8. data/MissingFeatures.md +64 -0
  9. data/README.md +161 -16
  10. data/Rakefile +30 -0
  11. data/TODO.md +41 -0
  12. data/features/env.rb +60 -0
  13. data/features/example.feature +120 -0
  14. data/features/step_definitions/example_steps.rb +46 -0
  15. data/lib/terrestrial/abstract_record.rb +99 -0
  16. data/lib/terrestrial/association_loaders.rb +52 -0
  17. data/lib/terrestrial/collection_mutability_proxy.rb +81 -0
  18. data/lib/terrestrial/configurations/conventional_association_configuration.rb +186 -0
  19. data/lib/terrestrial/configurations/conventional_configuration.rb +302 -0
  20. data/lib/terrestrial/dataset.rb +49 -0
  21. data/lib/terrestrial/deleted_record.rb +20 -0
  22. data/lib/terrestrial/dirty_map.rb +42 -0
  23. data/lib/terrestrial/graph_loader.rb +63 -0
  24. data/lib/terrestrial/graph_serializer.rb +91 -0
  25. data/lib/terrestrial/identity_map.rb +22 -0
  26. data/lib/terrestrial/lazy_collection.rb +74 -0
  27. data/lib/terrestrial/lazy_object_proxy.rb +55 -0
  28. data/lib/terrestrial/many_to_many_association.rb +138 -0
  29. data/lib/terrestrial/many_to_one_association.rb +66 -0
  30. data/lib/terrestrial/mapper_facade.rb +137 -0
  31. data/lib/terrestrial/one_to_many_association.rb +66 -0
  32. data/lib/terrestrial/public_conveniencies.rb +139 -0
  33. data/lib/terrestrial/query_order.rb +32 -0
  34. data/lib/terrestrial/relation_mapping.rb +50 -0
  35. data/lib/terrestrial/serializer.rb +18 -0
  36. data/lib/terrestrial/short_inspection_string.rb +18 -0
  37. data/lib/terrestrial/struct_factory.rb +17 -0
  38. data/lib/terrestrial/subset_queries_proxy.rb +11 -0
  39. data/lib/terrestrial/upserted_record.rb +15 -0
  40. data/lib/terrestrial/version.rb +1 -1
  41. data/lib/terrestrial.rb +5 -2
  42. data/sequel_mapper.gemspec +31 -0
  43. data/spec/config_override_spec.rb +193 -0
  44. data/spec/custom_serializers_spec.rb +49 -0
  45. data/spec/deletion_spec.rb +101 -0
  46. data/spec/graph_persistence_spec.rb +313 -0
  47. data/spec/graph_traversal_spec.rb +121 -0
  48. data/spec/new_graph_persistence_spec.rb +71 -0
  49. data/spec/object_identity_spec.rb +70 -0
  50. data/spec/ordered_association_spec.rb +51 -0
  51. data/spec/persistence_efficiency_spec.rb +224 -0
  52. data/spec/predefined_queries_spec.rb +62 -0
  53. data/spec/proxying_spec.rb +88 -0
  54. data/spec/querying_spec.rb +48 -0
  55. data/spec/readme_examples_spec.rb +35 -0
  56. data/spec/sequel_mapper/abstract_record_spec.rb +244 -0
  57. data/spec/sequel_mapper/collection_mutability_proxy_spec.rb +135 -0
  58. data/spec/sequel_mapper/deleted_record_spec.rb +59 -0
  59. data/spec/sequel_mapper/dirty_map_spec.rb +214 -0
  60. data/spec/sequel_mapper/lazy_collection_spec.rb +119 -0
  61. data/spec/sequel_mapper/lazy_object_proxy_spec.rb +140 -0
  62. data/spec/sequel_mapper/public_conveniencies_spec.rb +58 -0
  63. data/spec/sequel_mapper/upserted_record_spec.rb +59 -0
  64. data/spec/spec_helper.rb +36 -0
  65. data/spec/support/blog_schema.rb +38 -0
  66. data/spec/support/have_persisted_matcher.rb +19 -0
  67. data/spec/support/mapper_setup.rb +221 -0
  68. data/spec/support/mock_sequel.rb +193 -0
  69. data/spec/support/object_graph_setup.rb +139 -0
  70. data/spec/support/seed_data_setup.rb +165 -0
  71. data/spec/support/sequel_persistence_setup.rb +19 -0
  72. data/spec/support/sequel_test_support.rb +166 -0
  73. metadata +207 -13
  74. data/.travis.yml +0 -4
  75. data/bin/console +0 -14
  76. data/bin/setup +0 -7
  77. data/terrestrial.gemspec +0 -23
@@ -0,0 +1,58 @@
1
+ require "spec_helper"
2
+
3
+ require "terrestrial/public_conveniencies"
4
+
5
+ RSpec.describe Terrestrial::PublicConveniencies do
6
+ subject(:conveniences) {
7
+ Module.new.extend(Terrestrial::PublicConveniencies)
8
+ }
9
+
10
+ class MockDatastore < DelegateClass(Hash)
11
+ def transaction(&block)
12
+ block.call
13
+ end
14
+ end
15
+
16
+ describe "#mappers" do
17
+ let(:datastore) {
18
+ MockDatastore.new(
19
+ {
20
+ things: [ thing_record ],
21
+ }
22
+ )
23
+ }
24
+
25
+ let(:mapper_config) {
26
+ {
27
+ things: double(
28
+ :thing_config,
29
+ name: mapping_name,
30
+ namespace: :things,
31
+ fields: [:id],
32
+ associations: [],
33
+ primary_key: [],
34
+ factory: ->(x){x}
35
+ )
36
+ }
37
+ }
38
+
39
+ let(:mapping_name) { :things }
40
+
41
+ let(:thing_record) {
42
+ {
43
+ id: "THE THING",
44
+ }
45
+ }
46
+
47
+ it "returns a mapper for the specified mapping" do
48
+ mappers = conveniences.mappers(
49
+ mappings: mapper_config,
50
+ datastore: datastore,
51
+ )
52
+
53
+ expect(
54
+ mappers[:things].all.first.fetch(:id)
55
+ ).to eq("THE THING")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,59 @@
1
+ require "spec_helper"
2
+
3
+ require "terrestrial/upserted_record"
4
+
5
+ RSpec.describe Terrestrial::UpsertedRecord do
6
+ subject(:record) {
7
+ Terrestrial::UpsertedRecord.new(namespace, identity, raw_data)
8
+ }
9
+
10
+ let(:namespace) { double(:namespace) }
11
+
12
+ let(:identity) {
13
+ { id: id }
14
+ }
15
+
16
+ let(:raw_data) {
17
+ {
18
+ name: name,
19
+ }
20
+ }
21
+
22
+ let(:id) { double(:id) }
23
+ let(:name) { double(:name) }
24
+
25
+ describe "#if_upsert" do
26
+ it "invokes the callback" do
27
+ expect { |callback|
28
+ record.if_upsert(&callback)
29
+ }.to yield_with_args(record)
30
+ end
31
+ end
32
+
33
+ describe "#==" do
34
+ context "with another record that upserts" do
35
+ let(:comparitor) {
36
+ record.merge({})
37
+ }
38
+
39
+ it "is equal" do
40
+ expect(record.==(comparitor)).to be(true)
41
+ end
42
+ end
43
+
44
+ context "with another record that does not upsert" do
45
+ let(:comparitor) {
46
+ Class.new(Terrestrial::AbstractRecord) do
47
+ protected
48
+ def operation
49
+ :something_else
50
+ end
51
+ end
52
+ }
53
+
54
+ it "is not equal" do
55
+ expect(record.==(comparitor)).to be(false)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,36 @@
1
+ require "pry"
2
+ require "support/sequel_test_support"
3
+ require "support/blog_schema"
4
+
5
+ RSpec.configure do |config|
6
+ config.expect_with :rspec do |expectations|
7
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
8
+ end
9
+
10
+ config.mock_with :rspec do |mocks|
11
+ mocks.verify_partial_doubles = true
12
+ end
13
+
14
+ config.filter_run :focus
15
+ config.run_all_when_everything_filtered = true
16
+
17
+ config.disable_monkey_patching!
18
+
19
+ # TODO: get everything running without warnings
20
+ config.warnings = false
21
+
22
+ if config.files_to_run.one?
23
+ config.default_formatter = 'doc'
24
+ end
25
+
26
+ # config.profile_examples = 10
27
+
28
+ # config.order = :random
29
+
30
+ # Kernel.srand config.seed
31
+
32
+ config.before(:suite) do
33
+ Terrestrial::SequelTestSupport.drop_tables
34
+ Terrestrial::SequelTestSupport.create_tables(BLOG_SCHEMA)
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ BLOG_SCHEMA = {
2
+ tables: {
3
+ users: [
4
+ { name: :id, type: String, options: { primary_key: true } },
5
+ { name: :first_name, type: String },
6
+ { name: :last_name, type: String },
7
+ { name: :email, type: String },
8
+ ],
9
+ posts: [
10
+ { name: :id, type: String, options: { primary_key: true } },
11
+ { name: :subject, type: String },
12
+ { name: :body, type: String },
13
+ { name: :author_id, type: String},
14
+ { name: :created_at, type: DateTime },
15
+ ],
16
+ comments: [
17
+ { name: :id, type: String, options: { primary_key: true } },
18
+ { name: :body, type: String },
19
+ { name: :post_id, type: String },
20
+ { name: :commenter_id, type: String },
21
+ ],
22
+ categories: [
23
+ { name: :id, type: String, options: { primary_key: true } },
24
+ { name: :name, type: String },
25
+ ],
26
+ categories_to_posts: [
27
+ { name: :post_id, type: String },
28
+ { name: :category_id, type: String },
29
+ ],
30
+ },
31
+ foreign_keys: [
32
+ [:posts, :author_id, :users, :id],
33
+ [:comments, :post_id, :posts, :id],
34
+ [:comments, :commenter_id, :users, :id],
35
+ [:categories_to_posts, :post_id, :posts, :id],
36
+ [:categories_to_posts, :category_id, :categories, :id],
37
+ ]
38
+ }
@@ -0,0 +1,19 @@
1
+ RSpec::Matchers.define :have_persisted do |relation_name, data|
2
+ match do |datastore|
3
+ datastore[relation_name].find { |record|
4
+ if data.respond_to?(:===)
5
+ data === record
6
+ else
7
+ data == record
8
+ end
9
+ }
10
+ end
11
+
12
+ failure_message do |datastore|
13
+ "expected #{datastore[relation_name]} to have persisted #{data.inspect} in #{relation_name}"
14
+ end
15
+
16
+ failure_message_when_negated do |datastore|
17
+ failure_message.gsub("to have", "not to have")
18
+ end
19
+ end
@@ -0,0 +1,221 @@
1
+ require "terrestrial"
2
+ require "terrestrial/mapper_facade"
3
+ require "terrestrial/relation_mapping"
4
+ require "terrestrial/lazy_collection"
5
+ require "terrestrial/collection_mutability_proxy"
6
+ require "terrestrial/lazy_object_proxy"
7
+ require "terrestrial/dataset"
8
+ require "terrestrial/query_order"
9
+ require "terrestrial/one_to_many_association"
10
+ require "terrestrial/many_to_one_association"
11
+ require "terrestrial/many_to_many_association"
12
+ require "terrestrial/subset_queries_proxy"
13
+ require "support/object_graph_setup"
14
+
15
+ RSpec.shared_context "mapper setup" do
16
+ include_context "object graph setup"
17
+
18
+ let(:mappers) {
19
+ Terrestrial.mappers(mappings: mappings, datastore: datastore)
20
+ }
21
+
22
+ let(:user_mapper) { mappers[:users] }
23
+
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
+ ]
75
+ }
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: {},
218
+ }
219
+ }
220
+ }
221
+ end
@@ -0,0 +1,193 @@
1
+ class Terrestrial::MockSequel
2
+ def initialize(relations)
3
+ @relations = {}
4
+
5
+ relations.each do |table_name|
6
+ @relations[table_name] = Relation.new(self, [])
7
+ end
8
+
9
+ @reads, @writes, @deletes = 0, 0, 0
10
+ end
11
+
12
+ attr_reader :relations
13
+ private :relations
14
+
15
+ def [](table_name)
16
+ @relations.fetch(table_name)
17
+ end
18
+
19
+ def log_read
20
+ @reads += 1
21
+ end
22
+
23
+ def log_write
24
+ @writes += 1
25
+ end
26
+
27
+ def log_delete
28
+ @deletes += 1
29
+ end
30
+
31
+ def read_count
32
+ @reads
33
+ end
34
+
35
+ def write_count
36
+ @writes
37
+ end
38
+
39
+ def delete_count
40
+ @deletes
41
+ end
42
+
43
+ class Query
44
+ def initialize(criteria: {}, order: [], reverse: false, &block)
45
+ if block
46
+ raise NotImplementedError.new("Block filtering not implemented")
47
+ end
48
+
49
+ @criteria = criteria
50
+ @order_columns = order
51
+ @reverse_order = reverse
52
+ end
53
+
54
+ attr_reader :criteria, :order_columns
55
+
56
+ def where(new_criteria, &block)
57
+ self.class.new(
58
+ criteria: criteria.merge(new_criteria),
59
+ order: order,
60
+ reverse: reverse,
61
+ &block
62
+ )
63
+ end
64
+
65
+ def order(columns)
66
+ self.class.new(
67
+ criteria: criteria,
68
+ order: columns,
69
+ )
70
+ end
71
+
72
+ def reverse
73
+ self.class.new(
74
+ criteria: criteria,
75
+ order: order_columns,
76
+ reverse: true,
77
+ )
78
+ end
79
+
80
+ def reverse_order?
81
+ !!@reverse_order
82
+ end
83
+ end
84
+
85
+ class Relation
86
+ include Enumerable
87
+
88
+ def initialize(database, all_rows, applied_query: Query.new)
89
+ @database = database
90
+ @all_rows = all_rows
91
+ @applied_query = applied_query
92
+ end
93
+
94
+ attr_reader :database, :all_rows, :applied_query
95
+ private :database, :all_rows, :applied_query
96
+
97
+ def where(criteria, &block)
98
+ new_with_query(Query.new(criteria: criteria, &block))
99
+ end
100
+
101
+ def order(columns)
102
+ new_with_query(applied_query.order(columns))
103
+ end
104
+
105
+ def reverse
106
+ @applied_query = @applied_query.reverse
107
+ end
108
+
109
+ def to_a
110
+ database.log_read
111
+
112
+ matching_rows
113
+ end
114
+
115
+ def each(&block)
116
+ database.log_read
117
+
118
+ matching_rows.each(&block)
119
+ end
120
+
121
+ def delete
122
+ database.log_delete
123
+
124
+ matching_rows.each do |row_to_delete|
125
+ all_rows.delete(row_to_delete)
126
+ end
127
+ end
128
+
129
+ def insert(new_row)
130
+ database.log_write
131
+
132
+ all_rows.push(new_row)
133
+ end
134
+
135
+ def update(attrs)
136
+ database.log_write
137
+
138
+ # No need to get the rows from the canonical relation as the hashes can
139
+ # just be mutated in plaace.
140
+ matching_rows.each do |row|
141
+ attrs.each do |k, v|
142
+ row[k] = v
143
+ end
144
+ end
145
+ end
146
+
147
+ def empty?
148
+ database.log_read
149
+
150
+ matching_rows.empty?
151
+ end
152
+
153
+ private
154
+
155
+ def matching_rows
156
+ apply_sort(
157
+ equality_filter(all_rows, applied_query.criteria),
158
+ applied_query.order_columns,
159
+ applied_query.reverse_order?,
160
+ )
161
+ end
162
+
163
+ def apply_sort(rows, order_columns, reverse_order)
164
+ sorted_rows = rows.sort_by{ |row|
165
+ order_columns.map { |col| row.fetch(col) }
166
+ }
167
+
168
+ if reverse_order
169
+ sorted_rows.reverse
170
+ else
171
+ sorted_rows
172
+ end
173
+ end
174
+
175
+ def equality_filter(rows, criteria)
176
+ rows.select { |row|
177
+ criteria.all? { |k, v|
178
+ if v.is_a?(Enumerable)
179
+ v.include?(row.fetch(k))
180
+ else
181
+ row.fetch(k) == v
182
+ end
183
+ }
184
+ }
185
+ end
186
+
187
+ private
188
+
189
+ def new_with_query(query)
190
+ self.class.new(database, all_rows, applied_query: query)
191
+ end
192
+ end
193
+ end