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
data/TODO.md
CHANGED
@@ -3,16 +3,8 @@
|
|
3
3
|
In no particular order
|
4
4
|
|
5
5
|
## General
|
6
|
-
* Refactor, methods too
|
7
|
-
|
8
|
-
## Persistence
|
9
|
-
* Efficient saving
|
10
|
-
- Part one, if it wasn't loaded it wasn't modified, check identity map
|
11
|
-
- Part two, dirty tracking
|
12
|
-
|
13
|
-
## Configuration
|
14
|
-
* Automatic config generation based on schema, foreign keys etc
|
15
|
-
* Config to take either a classes or callable factory
|
6
|
+
* Refactor, methods too big, objects missing
|
7
|
+
* Name things better
|
16
8
|
|
17
9
|
## Querying
|
18
10
|
* Querying API, what would a repository with some arbitrary queries look like?
|
@@ -24,10 +16,23 @@ In no particular order
|
|
24
16
|
Sequel's `#where` with block [querying API](http://sequel.jeremyevans.net/rdoc/files/doc/cheat_sheet_rdoc.html#label-AND%2FOR%2FNOT)
|
25
17
|
|
26
18
|
## Associations
|
27
|
-
* Eager loading
|
28
19
|
* Read only associations
|
29
20
|
- Loaded objects would be immutable
|
30
21
|
- Collection proxy would have no #push or #remove
|
31
22
|
- Skipped when dumping
|
32
23
|
* Associations defined with a join
|
33
24
|
* Composable associations
|
25
|
+
|
26
|
+
# Hopefully done
|
27
|
+
|
28
|
+
## Persistence
|
29
|
+
* Efficient saving
|
30
|
+
- Part one, if it wasn't loaded it wasn't modified, check identity map
|
31
|
+
- Part two, dirty tracking
|
32
|
+
|
33
|
+
## Associations
|
34
|
+
* Eager loading
|
35
|
+
|
36
|
+
## Configuration
|
37
|
+
* Automatic config generation based on schema, foreign keys etc
|
38
|
+
* Config to take either a classes or callable factory
|
data/features/env.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require "pry"
|
2
|
+
require "sequel"
|
3
|
+
require "sequel_mapper"
|
4
|
+
require_relative "../spec/support/sequel_test_support"
|
5
|
+
|
6
|
+
module ExampleRunnerSupport
|
7
|
+
def example_eval_concat(code_strings)
|
8
|
+
example_eval(code_strings.join("\n"))
|
9
|
+
end
|
10
|
+
|
11
|
+
def example_eval(code_string)
|
12
|
+
example_module.module_eval(code_string)
|
13
|
+
rescue Object => e
|
14
|
+
binding.pry if ENV["DEBUG"]
|
15
|
+
raise e
|
16
|
+
end
|
17
|
+
|
18
|
+
def example_exec(&block)
|
19
|
+
example_exec.module_eval(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
def example_module
|
23
|
+
@example_module ||= Module.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def normalise_inspection_string(string)
|
27
|
+
string
|
28
|
+
.strip
|
29
|
+
.gsub(/[\n\s]+/, " ")
|
30
|
+
.gsub(/\:[0-9a-f]{12}/, ":<<object id removed>>")
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_schema_table(string)
|
34
|
+
string.each_line.drop(2).map { |line|
|
35
|
+
name, type = line.split("|").map(&:strip)
|
36
|
+
{
|
37
|
+
name: name,
|
38
|
+
type: type,
|
39
|
+
}
|
40
|
+
}
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
module DatabaseSupport
|
45
|
+
def create_table(name, schema)
|
46
|
+
SequelMapper::SequelTestSupport.create_tables(
|
47
|
+
name => schema,
|
48
|
+
)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
Before do
|
53
|
+
SequelMapper::SequelTestSupport.drop_tables
|
54
|
+
end
|
55
|
+
|
56
|
+
World(ExampleRunnerSupport)
|
57
|
+
World(DatabaseSupport)
|
@@ -0,0 +1,121 @@
|
|
1
|
+
Feature: Basic setup
|
2
|
+
|
3
|
+
Scenario: Setup with conventional configuration
|
4
|
+
Given the domain objects are defined
|
5
|
+
"""
|
6
|
+
User = Struct.new(:id, :first_name, :last_name, :email, :posts)
|
7
|
+
Post = Struct.new(:id, :author, :subject, :body, :created_at, :categories)
|
8
|
+
Category = Struct.new(:id, :name, :posts)
|
9
|
+
"""
|
10
|
+
And a conventionally similar database schema for table "users"
|
11
|
+
"""
|
12
|
+
Column | Type
|
13
|
+
------------ +---------
|
14
|
+
id | text
|
15
|
+
first_name | text
|
16
|
+
last_name | text
|
17
|
+
email | text
|
18
|
+
"""
|
19
|
+
And a conventionally similar database schema for table "posts"
|
20
|
+
"""
|
21
|
+
Column | Type
|
22
|
+
-------------+---------
|
23
|
+
id | text
|
24
|
+
author_id | text
|
25
|
+
subject | text
|
26
|
+
body | text
|
27
|
+
created_at | DateTime
|
28
|
+
"""
|
29
|
+
And a conventionally similar database schema for table "categories"
|
30
|
+
"""
|
31
|
+
Column | Type
|
32
|
+
-------------+---------
|
33
|
+
id | text
|
34
|
+
name | text
|
35
|
+
"""
|
36
|
+
And a conventionally similar database schema for table "categories_to_posts"
|
37
|
+
"""
|
38
|
+
Column | Type
|
39
|
+
-------------+---------
|
40
|
+
post_id | text
|
41
|
+
category_id | text
|
42
|
+
"""
|
43
|
+
And a database connection is established
|
44
|
+
"""
|
45
|
+
DB = Sequel.postgres(
|
46
|
+
host: ENV.fetch("PGHOST"),
|
47
|
+
user: ENV.fetch("PGUSER"),
|
48
|
+
database: ENV.fetch("PGDATABASE"),
|
49
|
+
)
|
50
|
+
"""
|
51
|
+
And the associations are defined in the mapper configuration
|
52
|
+
"""
|
53
|
+
USER_MAPPER_CONFIG = SequelMapper.config(DB)
|
54
|
+
.setup_mapping(:users) { |users|
|
55
|
+
users.class(User)
|
56
|
+
users.has_many(:posts, foreign_key: :author_id)
|
57
|
+
}
|
58
|
+
.setup_mapping(:posts) { |posts|
|
59
|
+
posts.class(Post)
|
60
|
+
posts.belongs_to(:author, mapping_name: :users)
|
61
|
+
posts.has_many_through(:categories)
|
62
|
+
}
|
63
|
+
.setup_mapping(:categories) { |categories|
|
64
|
+
categories.class(Category)
|
65
|
+
categories.has_many_through(:posts)
|
66
|
+
}
|
67
|
+
"""
|
68
|
+
And a mapper is instantiated
|
69
|
+
"""
|
70
|
+
USER_MAPPER = SequelMapper.mapper(
|
71
|
+
datastore: DB,
|
72
|
+
config: USER_MAPPER_CONFIG,
|
73
|
+
name: :users,
|
74
|
+
)
|
75
|
+
"""
|
76
|
+
When a new graph of objects are created
|
77
|
+
"""
|
78
|
+
user = User.new(
|
79
|
+
"2f0f791c-47cf-4a00-8676-e582075bcd65",
|
80
|
+
"Hansel",
|
81
|
+
"Trickett",
|
82
|
+
"hansel@tricketts.org",
|
83
|
+
[],
|
84
|
+
)
|
85
|
+
|
86
|
+
user.posts << Post.new(
|
87
|
+
"9b75fe2b-d694-4b90-9137-6201d426dda2",
|
88
|
+
user,
|
89
|
+
"Things that I like",
|
90
|
+
"I like fish and scratching",
|
91
|
+
Time.parse("2015-10-03 21:00:00 UTC"),
|
92
|
+
[],
|
93
|
+
)
|
94
|
+
"""
|
95
|
+
And the new graph is saved
|
96
|
+
"""
|
97
|
+
USER_MAPPER.save(user)
|
98
|
+
"""
|
99
|
+
And the following query is executed
|
100
|
+
"""
|
101
|
+
user = USER_MAPPER.where(id: "2f0f791c-47cf-4a00-8676-e582075bcd65").first
|
102
|
+
"""
|
103
|
+
Then the persisted user object is returned with lazy associations
|
104
|
+
"""
|
105
|
+
#<struct User id="2f0f791c-47cf-4a00-8676-e582075bcd65",
|
106
|
+
first_name="Hansel",
|
107
|
+
last_name="Trickett",
|
108
|
+
email="hansel@tricketts.org",
|
109
|
+
posts=#<SequelMapper::CollectionMutabilityProxy:7fa4817aa148
|
110
|
+
>>
|
111
|
+
"""
|
112
|
+
And the user's posts will be loaded once the association proxy receives an Enumerable message
|
113
|
+
"""
|
114
|
+
[#<struct Post id="9b75fe2b-d694-4b90-9137-6201d426dda2",
|
115
|
+
author=#<SequelMapper::LazyObjectProxy:7fec5ac2a5f8 known_fields={:id=>"2f0f791c-47cf-4a00-8676-e582075bcd65"} lazy_object=nil>,
|
116
|
+
subject="Things that I like",
|
117
|
+
body="I like fish and scratching",
|
118
|
+
created_at=2015-10-03 21:00:00 UTC,
|
119
|
+
categories=#<SequelMapper::CollectionMutabilityProxy:7fec5ac296f8
|
120
|
+
>>]
|
121
|
+
"""
|
@@ -0,0 +1,46 @@
|
|
1
|
+
Given(/^the domain objects are defined$/) do |code_sample|
|
2
|
+
Object.module_eval(code_sample)
|
3
|
+
end
|
4
|
+
|
5
|
+
Given(/^a database connection is established$/) do |code_sample|
|
6
|
+
example_eval(code_sample)
|
7
|
+
end
|
8
|
+
|
9
|
+
Given(/^the associations are defined in the mapper configuration$/) do |code_sample|
|
10
|
+
example_eval(code_sample)
|
11
|
+
end
|
12
|
+
|
13
|
+
Given(/^a mapper is instantiated$/) do |code_sample|
|
14
|
+
example_eval(code_sample)
|
15
|
+
end
|
16
|
+
|
17
|
+
Given(/^a conventionally similar database schema for table "(.*?)"$/) do |table_name, schema_table|
|
18
|
+
create_table(table_name, parse_schema_table(schema_table))
|
19
|
+
end
|
20
|
+
|
21
|
+
When(/^a new graph of objects are created$/) do |code_sample|
|
22
|
+
@objects_to_be_saved_sample = code_sample
|
23
|
+
end
|
24
|
+
|
25
|
+
When(/^the new graph is saved$/) do |save_objects_code|
|
26
|
+
example_eval(
|
27
|
+
[@objects_to_be_saved_sample, save_objects_code].join("\n")
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
When(/^the following query is executed$/) do |code_sample|
|
32
|
+
@query = code_sample
|
33
|
+
@result = example_eval(code_sample)
|
34
|
+
end
|
35
|
+
|
36
|
+
Then(/^the persisted user object is returned with lazy associations$/) do |expected_inspection_string|
|
37
|
+
expect(normalise_inspection_string(@result.inspect))
|
38
|
+
.to eq(normalise_inspection_string(expected_inspection_string))
|
39
|
+
end
|
40
|
+
|
41
|
+
Then(/^the user's posts will be loaded once the association proxy receives an Enumerable message$/) do |expected_inspection_string|
|
42
|
+
posts = @result.posts.to_a
|
43
|
+
|
44
|
+
expect(normalise_inspection_string(posts.inspect))
|
45
|
+
.to eq(normalise_inspection_string(expected_inspection_string))
|
46
|
+
end
|
data/lib/sequel_mapper.rb
CHANGED
@@ -0,0 +1,53 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
|
3
|
+
module SequelMapper
|
4
|
+
class AbstractRecord
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
def initialize(namespace, identity, raw_data = {})
|
8
|
+
@namespace = namespace
|
9
|
+
@identity = identity
|
10
|
+
@raw_data = raw_data
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :namespace, :identity
|
14
|
+
|
15
|
+
attr_reader :raw_data
|
16
|
+
private :raw_data
|
17
|
+
|
18
|
+
def_delegators :to_h, :fetch
|
19
|
+
|
20
|
+
def if_upsert(&block)
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def if_delete(&block)
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def merge(more_data)
|
29
|
+
new_with_raw_data(raw_data.merge(more_data))
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_h
|
33
|
+
raw_data.merge(identity)
|
34
|
+
end
|
35
|
+
|
36
|
+
def ==(other)
|
37
|
+
self.class === other &&
|
38
|
+
[operation, to_h] == [other.operation, other.to_h]
|
39
|
+
end
|
40
|
+
|
41
|
+
protected
|
42
|
+
|
43
|
+
def operation
|
44
|
+
raise NotImplementedError
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def new_with_raw_data(new_raw_data)
|
50
|
+
self.class.new(namespace, identity, new_raw_data)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module SequelMapper
|
2
|
+
module AssociationLoaders
|
3
|
+
class OneToMany
|
4
|
+
def initialize(type:, mapping_name:, foreign_key:, key:, proxy_factory:)
|
5
|
+
@type = type
|
6
|
+
@mapping_name = mapping_name
|
7
|
+
@foreign_key = foreign_key
|
8
|
+
@key = key
|
9
|
+
@proxy_factory = proxy_factory
|
10
|
+
@eager_loads = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :type, :mapping_name, :foreign_key, :key, :proxy_factory
|
14
|
+
private :type, :mapping_name, :foreign_key, :key, :proxy_factory
|
15
|
+
|
16
|
+
def fetch(*args, &block)
|
17
|
+
{
|
18
|
+
key: key,
|
19
|
+
foreign_key: foreign_key,
|
20
|
+
type: type,
|
21
|
+
mapping_name: mapping_name,
|
22
|
+
}.fetch(*args, &block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def call(mappings, record, &object_pipeline)
|
26
|
+
mapping = mappings.fetch(mapping_name)
|
27
|
+
|
28
|
+
proxy_factory.call(
|
29
|
+
query: query(mapping, record),
|
30
|
+
loader: object_pipeline.call(mapping),
|
31
|
+
association_loader: self,
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def query(mapping, record)
|
36
|
+
foreign_key_value = record.fetch(key)
|
37
|
+
|
38
|
+
->(datastore) {
|
39
|
+
@eager_loads.fetch(record) {
|
40
|
+
datastore[mapping.namespace]
|
41
|
+
.where(foreign_key => foreign_key_value)
|
42
|
+
}
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def eager_load(dataset, association_name)
|
47
|
+
datastore[mapping.namespace]
|
48
|
+
.where(foreign_key => dataset.select(key))
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "sequel_mapper/short_inspection_string"
|
3
|
+
|
4
|
+
module SequelMapper
|
5
|
+
class CollectionMutabilityProxy
|
6
|
+
include ShortInspectionString
|
7
|
+
|
8
|
+
def initialize(collection)
|
9
|
+
@collection = collection
|
10
|
+
@added_nodes = []
|
11
|
+
@deleted_nodes = []
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :collection, :deleted_nodes, :added_nodes
|
15
|
+
private :collection, :deleted_nodes, :added_nodes
|
16
|
+
|
17
|
+
extend Forwardable
|
18
|
+
def_delegators :collection, :where, :subset
|
19
|
+
|
20
|
+
def each_loaded(&block)
|
21
|
+
loaded_enum.each(&block)
|
22
|
+
end
|
23
|
+
|
24
|
+
def each_deleted(&block)
|
25
|
+
@deleted_nodes.each(&block)
|
26
|
+
end
|
27
|
+
|
28
|
+
include Enumerable
|
29
|
+
def each(&block)
|
30
|
+
if block
|
31
|
+
enum.each(&block)
|
32
|
+
self
|
33
|
+
else
|
34
|
+
enum
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def delete(node)
|
39
|
+
@deleted_nodes.push(node)
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def push(node)
|
44
|
+
@added_nodes.push(node)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def loaded_enum
|
50
|
+
Enumerator.new do |yielder|
|
51
|
+
collection.each_loaded do |element|
|
52
|
+
yielder.yield(element) unless deleted?(element)
|
53
|
+
end
|
54
|
+
|
55
|
+
added_nodes.each do |node|
|
56
|
+
yielder.yield(node)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def enum
|
62
|
+
Enumerator.new do |yielder|
|
63
|
+
collection.each do |element|
|
64
|
+
yielder.yield(element) unless deleted?(element)
|
65
|
+
end
|
66
|
+
|
67
|
+
added_nodes.each do |node|
|
68
|
+
yielder.yield(node)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def deleted?(node)
|
74
|
+
@deleted_nodes.include?(node)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|