sequel_mapper 0.0.1 → 0.0.3
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 +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
|