terrestrial 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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