sequel_mapper 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/CODE_OF_CONDUCT.md +28 -0
- data/Gemfile.lock +32 -2
- data/MissingFeatures.md +64 -0
- data/README.md +141 -72
- data/Rakefile +29 -0
- data/TODO.md +16 -11
- data/features/env.rb +57 -0
- data/features/example.feature +121 -0
- data/features/step_definitions/example_steps.rb +46 -0
- data/lib/sequel_mapper.rb +6 -2
- data/lib/sequel_mapper/abstract_record.rb +53 -0
- data/lib/sequel_mapper/association_loaders.rb +52 -0
- data/lib/sequel_mapper/collection_mutability_proxy.rb +77 -0
- data/lib/sequel_mapper/configurations/conventional_association_configuration.rb +187 -0
- data/lib/sequel_mapper/configurations/conventional_configuration.rb +269 -0
- data/lib/sequel_mapper/dataset.rb +37 -0
- data/lib/sequel_mapper/deleted_record.rb +16 -0
- data/lib/sequel_mapper/dirty_map.rb +31 -0
- data/lib/sequel_mapper/graph_loader.rb +48 -0
- data/lib/sequel_mapper/graph_serializer.rb +107 -0
- data/lib/sequel_mapper/identity_map.rb +22 -0
- data/lib/sequel_mapper/lazy_object_proxy.rb +51 -0
- data/lib/sequel_mapper/many_to_many_association.rb +181 -0
- data/lib/sequel_mapper/many_to_one_association.rb +60 -0
- data/lib/sequel_mapper/mapper_facade.rb +180 -0
- data/lib/sequel_mapper/one_to_many_association.rb +51 -0
- data/lib/sequel_mapper/public_conveniencies.rb +27 -0
- data/lib/sequel_mapper/query_order.rb +32 -0
- data/lib/sequel_mapper/queryable_lazy_dataset_loader.rb +70 -0
- data/lib/sequel_mapper/relation_mapping.rb +35 -0
- data/lib/sequel_mapper/serializer.rb +18 -0
- data/lib/sequel_mapper/short_inspection_string.rb +18 -0
- data/lib/sequel_mapper/subset_queries_proxy.rb +11 -0
- data/lib/sequel_mapper/upserted_record.rb +15 -0
- data/lib/sequel_mapper/version.rb +1 -1
- data/sequel_mapper.gemspec +3 -0
- data/spec/config_override_spec.rb +167 -0
- data/spec/custom_serializers_spec.rb +77 -0
- data/spec/deletion_spec.rb +104 -0
- data/spec/graph_persistence_spec.rb +83 -88
- data/spec/graph_traversal_spec.rb +32 -31
- data/spec/new_graph_persistence_spec.rb +69 -0
- data/spec/object_identity_spec.rb +70 -0
- data/spec/ordered_association_spec.rb +46 -16
- data/spec/persistence_efficiency_spec.rb +186 -0
- data/spec/predefined_queries_spec.rb +73 -0
- data/spec/proxying_spec.rb +25 -19
- data/spec/querying_spec.rb +24 -27
- data/spec/readme_examples_spec.rb +35 -0
- data/spec/sequel_mapper/abstract_record_spec.rb +179 -0
- data/spec/sequel_mapper/{association_proxy_spec.rb → collection_mutability_proxy_spec.rb} +6 -6
- data/spec/sequel_mapper/deleted_record_spec.rb +59 -0
- data/spec/sequel_mapper/lazy_object_proxy_spec.rb +140 -0
- data/spec/sequel_mapper/public_conveniencies_spec.rb +49 -0
- data/spec/sequel_mapper/queryable_lazy_dataset_loader_spec.rb +103 -0
- data/spec/sequel_mapper/upserted_record_spec.rb +59 -0
- data/spec/spec_helper.rb +7 -10
- data/spec/support/blog_schema.rb +29 -0
- data/spec/support/have_persisted_matcher.rb +19 -0
- data/spec/support/mapper_setup.rb +234 -0
- data/spec/support/mock_sequel.rb +0 -1
- data/spec/support/object_graph_setup.rb +106 -0
- data/spec/support/seed_data_setup.rb +122 -0
- data/spec/support/sequel_persistence_setup.rb +19 -0
- data/spec/support/sequel_test_support.rb +159 -0
- metadata +121 -15
- data/lib/sequel_mapper/association_proxy.rb +0 -54
- data/lib/sequel_mapper/belongs_to_association_proxy.rb +0 -27
- data/lib/sequel_mapper/graph.rb +0 -174
- data/lib/sequel_mapper/queryable_association_proxy.rb +0 -23
- data/spec/sequel_mapper/belongs_to_association_proxy_spec.rb +0 -65
- data/spec/support/graph_fixture.rb +0 -331
- data/spec/support/query_counter.rb +0 -29
@@ -0,0 +1,37 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
class Dataset
|
3
|
+
def initialize(records)
|
4
|
+
@records = records
|
5
|
+
end
|
6
|
+
|
7
|
+
attr_reader :records
|
8
|
+
private :records
|
9
|
+
|
10
|
+
include Enumerable
|
11
|
+
|
12
|
+
def each(&block)
|
13
|
+
records.each(&block)
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
def where(criteria)
|
18
|
+
new(
|
19
|
+
records.select { |row|
|
20
|
+
criteria.all? { |k, v|
|
21
|
+
row.fetch(k, :nope) == v
|
22
|
+
}
|
23
|
+
}
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def select(field)
|
28
|
+
map { |data| data.fetch(field) }
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def new(records)
|
34
|
+
self.class.new(records)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
class DirtyMap
|
3
|
+
def initialize(storage)
|
4
|
+
@storage = storage
|
5
|
+
end
|
6
|
+
|
7
|
+
attr_reader :storage
|
8
|
+
private :storage
|
9
|
+
|
10
|
+
def load(record)
|
11
|
+
storage.store(hash_key(record), deep_clone(record))
|
12
|
+
record
|
13
|
+
end
|
14
|
+
|
15
|
+
def dirty?(record)
|
16
|
+
record_as_loaded = storage.fetch(hash_key(record), :not_found)
|
17
|
+
|
18
|
+
record != record_as_loaded
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def hash_key(record)
|
24
|
+
deep_clone([record.namespace, record.identity])
|
25
|
+
end
|
26
|
+
|
27
|
+
def deep_clone(record)
|
28
|
+
Marshal.load(Marshal.dump(record))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
class GraphLoader
|
3
|
+
def initialize(datasets:, mappings:, object_load_pipeline:)
|
4
|
+
@datasets = datasets
|
5
|
+
@mappings = mappings
|
6
|
+
@object_load_pipeline = object_load_pipeline
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :datasets, :mappings, :object_load_pipeline
|
10
|
+
|
11
|
+
def call(mapping_name, record, eager_data = {})
|
12
|
+
mapping = mappings.fetch(mapping_name)
|
13
|
+
|
14
|
+
load_record(mapping, record, eager_data)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def load_record(mapping, record, eager_data)
|
20
|
+
associations = load_associations(mapping, record, eager_data)
|
21
|
+
|
22
|
+
object_load_pipeline.call(mapping, record, Hash[associations])
|
23
|
+
end
|
24
|
+
|
25
|
+
def load_associations(mapping, record, eager_data)
|
26
|
+
mapping.associations.map { |name, association|
|
27
|
+
data_superset = eager_data.fetch([mapping.name, name]) {
|
28
|
+
datasets[mappings.fetch(association.mapping_name).namespace]
|
29
|
+
}
|
30
|
+
|
31
|
+
[
|
32
|
+
name,
|
33
|
+
association.build_proxy(
|
34
|
+
record: record,
|
35
|
+
data_superset: data_superset,
|
36
|
+
loader: ->(associated_record, join_records = []) {
|
37
|
+
join_records.map { |jr|
|
38
|
+
join_mapping = mappings.fetch(association.join_mapping_name)
|
39
|
+
object_load_pipeline.call(join_mapping, jr)
|
40
|
+
}
|
41
|
+
call(association.mapping_name, associated_record, eager_data)
|
42
|
+
},
|
43
|
+
)
|
44
|
+
]
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require "sequel_mapper/upserted_record"
|
2
|
+
require "sequel_mapper/deleted_record"
|
3
|
+
|
4
|
+
module SequelMapper
|
5
|
+
class GraphSerializer
|
6
|
+
def initialize(mappings:)
|
7
|
+
@mappings = mappings
|
8
|
+
@count = 0
|
9
|
+
@encountered_records = Set.new
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :mappings, :encountered_records
|
13
|
+
private :mappings, :encountered_records
|
14
|
+
|
15
|
+
def call(mapping_name, object, foreign_key = {})
|
16
|
+
# TODO may need some attention :)
|
17
|
+
mapping = mappings.fetch(mapping_name)
|
18
|
+
serializer = mapping.serializer
|
19
|
+
namespace = mapping.namespace
|
20
|
+
primary_key = mapping.primary_key
|
21
|
+
fields = mapping.fields
|
22
|
+
associations_map = mapping.associations
|
23
|
+
|
24
|
+
serialized_record = serializer.call(object)
|
25
|
+
|
26
|
+
current_record = UpsertedRecord.new(
|
27
|
+
namespace,
|
28
|
+
record_identity(primary_key, serialized_record),
|
29
|
+
serialized_record
|
30
|
+
.select { |k, _v| fields.include?(k) }
|
31
|
+
.merge(foreign_key)
|
32
|
+
)
|
33
|
+
|
34
|
+
if encountered_records.include?(current_record.identity)
|
35
|
+
return [current_record]
|
36
|
+
else
|
37
|
+
encountered_records.add(current_record.identity)
|
38
|
+
end
|
39
|
+
|
40
|
+
[current_record] + associations_map
|
41
|
+
.map { |name, association|
|
42
|
+
[serialized_record.fetch(name), association]
|
43
|
+
}
|
44
|
+
.map { |collection, association|
|
45
|
+
[nodes(collection), deleted_nodes(collection), association]
|
46
|
+
}
|
47
|
+
.map { |nodes, deleted_nodes, association|
|
48
|
+
assoc_mapping = mappings.fetch(association.mapping_name)
|
49
|
+
|
50
|
+
association.dump(current_record, nodes) { |assoc_mapping_name, assoc_object, foreign_key|
|
51
|
+
call(assoc_mapping_name, assoc_object, foreign_key)
|
52
|
+
} +
|
53
|
+
association.delete(current_record, deleted_nodes) { |assoc_mapping_name, assoc_object, foreign_key|
|
54
|
+
delete(assoc_mapping_name, assoc_object, foreign_key)
|
55
|
+
}
|
56
|
+
}
|
57
|
+
.flatten(1)
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def delete(mapping_name, object, _foreign_key)
|
63
|
+
# TODO copypasta ¯\_(ツ)_/¯
|
64
|
+
mapping = mappings.fetch(mapping_name)
|
65
|
+
primary_key = mapping.primary_key
|
66
|
+
serializer = mapping.serializer
|
67
|
+
namespace = mapping.namespace
|
68
|
+
|
69
|
+
serialized_record = serializer.call(object)
|
70
|
+
|
71
|
+
[
|
72
|
+
DeletedRecord.new(
|
73
|
+
namespace,
|
74
|
+
record_identity(primary_key, serialized_record),
|
75
|
+
)
|
76
|
+
]
|
77
|
+
end
|
78
|
+
|
79
|
+
def nodes(collection)
|
80
|
+
if collection.respond_to?(:each_loaded)
|
81
|
+
collection.each_loaded
|
82
|
+
elsif collection.is_a?(Struct)
|
83
|
+
[collection]
|
84
|
+
elsif collection.respond_to?(:each)
|
85
|
+
collection.each
|
86
|
+
else
|
87
|
+
collection
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def deleted_nodes(collection)
|
92
|
+
if collection.respond_to?(:each_deleted)
|
93
|
+
collection.each_deleted
|
94
|
+
else
|
95
|
+
[]
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def record_identity(primary_key, record)
|
100
|
+
Hash[
|
101
|
+
primary_key.map { |field|
|
102
|
+
[field, record.fetch(field)]
|
103
|
+
}
|
104
|
+
]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
class IdentityMap
|
3
|
+
def initialize(storage)
|
4
|
+
@storage = storage
|
5
|
+
end
|
6
|
+
|
7
|
+
attr_reader :storage
|
8
|
+
private :storage
|
9
|
+
|
10
|
+
def call(record, object)
|
11
|
+
storage.fetch(hash_key(record)) {
|
12
|
+
storage.store(hash_key(record), object)
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def hash_key(record)
|
19
|
+
[record.namespace, record.identity]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
class LazyObjectProxy
|
3
|
+
include ShortInspectionString
|
4
|
+
|
5
|
+
def initialize(object_loader, known_fields)
|
6
|
+
@object_loader = object_loader
|
7
|
+
@known_fields = known_fields
|
8
|
+
@lazy_object = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :object_loader, :known_fields
|
12
|
+
private :object_loader, :known_fields
|
13
|
+
|
14
|
+
def method_missing(method_id, *args, &block)
|
15
|
+
if args.empty? && known_fields.include?(method_id)
|
16
|
+
known_fields.fetch(method_id)
|
17
|
+
else
|
18
|
+
lazy_object.public_send(method_id, *args, &block)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def loaded?
|
23
|
+
!!@lazy_object
|
24
|
+
end
|
25
|
+
|
26
|
+
def __getobj__
|
27
|
+
lazy_object
|
28
|
+
end
|
29
|
+
|
30
|
+
def each_loaded(&block)
|
31
|
+
[self].select(&:loaded?).each(&block)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def respond_to_missing?(method_id, _include_private = false)
|
37
|
+
known_fields.include?(method_id) || lazy_object.respond_to?(method_id)
|
38
|
+
end
|
39
|
+
|
40
|
+
def lazy_object
|
41
|
+
@lazy_object ||= object_loader.call
|
42
|
+
end
|
43
|
+
|
44
|
+
def inspectable_properties
|
45
|
+
[
|
46
|
+
:known_fields,
|
47
|
+
:lazy_object,
|
48
|
+
]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
require "sequel_mapper/dataset"
|
2
|
+
|
3
|
+
module SequelMapper
|
4
|
+
class ManyToManyAssociation
|
5
|
+
def initialize(mapping_name:, foreign_key:, key:, proxy_factory:, association_foreign_key:, association_key:, join_mapping_name:, join_dataset:, order:)
|
6
|
+
@mapping_name = mapping_name
|
7
|
+
@foreign_key = foreign_key
|
8
|
+
@key = key
|
9
|
+
@proxy_factory = proxy_factory
|
10
|
+
@association_foreign_key = association_foreign_key
|
11
|
+
@association_key = association_key
|
12
|
+
@join_mapping_name = join_mapping_name
|
13
|
+
@join_dataset = join_dataset
|
14
|
+
@order = order
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :mapping_name, :join_mapping_name
|
18
|
+
|
19
|
+
attr_reader :foreign_key, :key, :proxy_factory, :association_key, :association_foreign_key, :join_dataset, :order
|
20
|
+
private :foreign_key, :key, :proxy_factory, :association_key, :association_foreign_key, :join_dataset, :order
|
21
|
+
|
22
|
+
def build_proxy(data_superset:, loader:, record:)
|
23
|
+
proxy_factory.call(
|
24
|
+
query: build_query(data_superset, record),
|
25
|
+
loader: ->(record_list) {
|
26
|
+
record = record_list.first
|
27
|
+
join_records = record_list.last
|
28
|
+
|
29
|
+
loader.call(record, join_records)
|
30
|
+
},
|
31
|
+
mapping_name: mapping_name,
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def eager_superset(superset, associated_dataset)
|
36
|
+
# TODO: All these keys can be confusing, write some focused tests.
|
37
|
+
eager_join_dataset = Dataset.new(
|
38
|
+
join_dataset
|
39
|
+
.where(foreign_key => associated_dataset.select(association_key))
|
40
|
+
.to_a
|
41
|
+
)
|
42
|
+
|
43
|
+
eager_dataset = superset
|
44
|
+
.where(key => eager_join_dataset.select(association_foreign_key))
|
45
|
+
.to_a
|
46
|
+
|
47
|
+
JoinedDataset.new(eager_dataset, eager_join_dataset)
|
48
|
+
end
|
49
|
+
|
50
|
+
def build_query(superset, parent_record)
|
51
|
+
order
|
52
|
+
.apply(
|
53
|
+
superset.join(join_mapping_name, association_foreign_key => key)
|
54
|
+
.where(foreign_key => foreign_key_value(parent_record))
|
55
|
+
)
|
56
|
+
.lazy.map { |record|
|
57
|
+
[record, [foreign_keys(parent_record, record)]]
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
class JoinedDataset < Dataset
|
62
|
+
def initialize(records, join_records)
|
63
|
+
@records = records
|
64
|
+
@join_records = join_records
|
65
|
+
end
|
66
|
+
|
67
|
+
def join(_relation_name, _conditions)
|
68
|
+
# TODO: This works for the current test suite but is probably too
|
69
|
+
# simplistic. Perhaps if the dataset was aware of its join conditions
|
70
|
+
# it would be able to intellegently skip joining or delegate
|
71
|
+
self
|
72
|
+
end
|
73
|
+
|
74
|
+
def where(criteria)
|
75
|
+
self.class.new(
|
76
|
+
*decompose_set(
|
77
|
+
find_like_sequel(criteria)
|
78
|
+
)
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def decompose_set(set)
|
85
|
+
set.map(&:to_pair).transpose.+([ [], [] ]).take(2)
|
86
|
+
end
|
87
|
+
|
88
|
+
def find_like_sequel(criteria)
|
89
|
+
joined_records
|
90
|
+
.select { |record|
|
91
|
+
criteria.all? { |k, v|
|
92
|
+
record.fetch(k, :nope) == v
|
93
|
+
}
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
def joined_records
|
98
|
+
# TODO: there will inevitably nearly always be a mismatch between the
|
99
|
+
# number of records and unique join records. This zip/transpose
|
100
|
+
# approach may be too simplistic.
|
101
|
+
@joined_records ||= records
|
102
|
+
.zip(@join_records)
|
103
|
+
.map { |record, join_record|
|
104
|
+
JoinedRecord.new(record, join_record)
|
105
|
+
}
|
106
|
+
end
|
107
|
+
|
108
|
+
class JoinedRecord
|
109
|
+
def initialize(record, join_record)
|
110
|
+
@record = record
|
111
|
+
@join_record = join_record
|
112
|
+
end
|
113
|
+
|
114
|
+
attr_reader :record, :join_record
|
115
|
+
private :record, :join_record
|
116
|
+
|
117
|
+
def to_pair
|
118
|
+
[record, join_record]
|
119
|
+
end
|
120
|
+
|
121
|
+
def to_h
|
122
|
+
@record
|
123
|
+
end
|
124
|
+
|
125
|
+
def fetch(key, default = NO_DEFAULT, &block)
|
126
|
+
args = [key, default].reject { |a| a == NO_DEFAULT }
|
127
|
+
|
128
|
+
@record.fetch(key) {
|
129
|
+
@join_record.fetch(*args, &block)
|
130
|
+
}
|
131
|
+
end
|
132
|
+
|
133
|
+
NO_DEFAULT = Module.new
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def dump(parent_record, collection, &block)
|
138
|
+
flat_list_of_records_and_join_records(parent_record, collection, &block)
|
139
|
+
end
|
140
|
+
|
141
|
+
def delete(parent_record, collection, &block)
|
142
|
+
flat_list_of_just_join_records(parent_record, collection, &block)
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def flat_list_of_records_and_join_records(parent_record, collection, &block)
|
148
|
+
record_join_record_pairs(parent_record, collection, &block).flatten(1)
|
149
|
+
end
|
150
|
+
|
151
|
+
def flat_list_of_just_join_records(parent_record, collection, &block)
|
152
|
+
record_join_record_pairs(parent_record, collection, &block)
|
153
|
+
.map { |(_records, join_records)| join_records }
|
154
|
+
.flatten(1)
|
155
|
+
end
|
156
|
+
|
157
|
+
def record_join_record_pairs(parent_record, collection, &block)
|
158
|
+
(collection || []).map { |associated_object|
|
159
|
+
records = block.call(mapping_name, associated_object, _no_foreign_key = {})
|
160
|
+
|
161
|
+
join_records = records.take(1).flat_map { |record|
|
162
|
+
fks = foreign_keys(parent_record, record)
|
163
|
+
block.call(join_mapping_name, fks, fks)
|
164
|
+
}
|
165
|
+
|
166
|
+
records + join_records
|
167
|
+
}
|
168
|
+
end
|
169
|
+
|
170
|
+
def foreign_keys(parent_record, record)
|
171
|
+
{
|
172
|
+
foreign_key => foreign_key_value(parent_record),
|
173
|
+
association_foreign_key => record.fetch(association_key),
|
174
|
+
}
|
175
|
+
end
|
176
|
+
|
177
|
+
def foreign_key_value(record)
|
178
|
+
record.fetch(key)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|