sequel_mapper 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2a26f63ff570a4dace02b8407237d137e91fcc86
4
+ data.tar.gz: 682421f11a6baccefbe7969510f345d17ea26d71
5
+ SHA512:
6
+ metadata.gz: ebc65d4a2915be9f85d269c417f47d96bec45255601a8a389b88c5bdc009420fac2c9f65ad64c8cdb13fea33a877b3d8db0274264e6f3242cd2cff3f207a6111
7
+ data.tar.gz: 6742b37bb795d34aeadc8fd765bc76976cafcf0e5c1b24873dc5720d1631b07cca1391c3914774acb4b8026514830ea13dcd9bcb7a35d2cb762042fc712898b3
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ .env
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.1.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sequel_mapper.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,43 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sequel_mapper (0.0.1)
5
+ sequel (~> 4.16)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ coderay (1.1.0)
11
+ diff-lcs (1.2.5)
12
+ method_source (0.8.2)
13
+ pg (0.17.1)
14
+ pry (0.10.1)
15
+ coderay (~> 1.1.0)
16
+ method_source (~> 0.8.1)
17
+ slop (~> 3.4)
18
+ rake (10.1.0)
19
+ rspec (3.1.0)
20
+ rspec-core (~> 3.1.0)
21
+ rspec-expectations (~> 3.1.0)
22
+ rspec-mocks (~> 3.1.0)
23
+ rspec-core (3.1.7)
24
+ rspec-support (~> 3.1.0)
25
+ rspec-expectations (3.1.2)
26
+ diff-lcs (>= 1.2.0, < 2.0)
27
+ rspec-support (~> 3.1.0)
28
+ rspec-mocks (3.1.3)
29
+ rspec-support (~> 3.1.0)
30
+ rspec-support (3.1.2)
31
+ sequel (4.16.0)
32
+ slop (3.6.0)
33
+
34
+ PLATFORMS
35
+ ruby
36
+
37
+ DEPENDENCIES
38
+ bundler (~> 1.7)
39
+ pg (~> 0.17.1)
40
+ pry (~> 0.10.1)
41
+ rake (~> 10.0)
42
+ rspec (~> 3.1)
43
+ sequel_mapper!
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Stephen Best
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # SequelMapper
2
+
3
+ **Very new, much experimental, so incomplete**
4
+
5
+ ## What it is
6
+
7
+ SequelMapper is a data mapper that pulls rows out of your database and maps
8
+ them into a graph of plain Ruby objects. The graph can then be modifed and
9
+ persisted back into the database as a whole.
10
+
11
+ The main feature is that it fully supports all the kinds of data associations
12
+ that you are used to with ActiveRecord but for your POROs.
13
+
14
+ It is built on top of Jeremy Evans' Sequel library.
15
+
16
+ ## Why is it?
17
+
18
+ * It seems like ROM may not be finished any time soon and I felt I could put
19
+ together something functional albeit less ambitious
20
+ * I love the Sequel library
21
+ * I love decoupling persistence
22
+ * Writing a complex datamapper is sure way to stall your project so I'm writing
23
+ one for you
24
+ * I am a sick person who enjoys this sort of thing
25
+
26
+ So go on, persist those POROs, they don't even have to know about it.
27
+
28
+ ## Example
29
+
30
+ ```ruby
31
+ # Let's say you have some domain objects
32
+
33
+ User = Struct.new(:id, :first_name, :last_name, :email, :posts)
34
+ Post = Struct.new(:id, :author, :subject, :body, :comments, :categories)
35
+ Comment = Struct.new(:id, :post, :commenter, :body)
36
+ Category = Struct.new(:id, :name, :posts)
37
+
38
+ # And a relational database with some tables that look similar
39
+
40
+ DB = Sequel.postgres(
41
+ host: ENV.fetch("PGHOST"),
42
+ user: ENV.fetch("PGUSER"),
43
+ database: ENV.fetch("PGDATABASE"),
44
+ )
45
+
46
+ user_mapper = SequelMapper::Graph.new(
47
+ top_level_namespace: :users,
48
+ datastore: DB,
49
+ config: mapper_config, # Config omitted
50
+ )
51
+
52
+ # Then this may appeal to you
53
+
54
+ user = user_mapper.where(id: 1).first
55
+ # => [#<struct User
56
+ # id=1,
57
+ # first_name="Stephen",
58
+ # last_name="Best",
59
+ # email="bestie@gmail.com",
60
+ # posts=#<SequelMapper::AssociationProxy:0x007ffbc3c7cb50 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>>]
61
+
62
+ user.posts
63
+ # => #<SequelMapper::AssociationProxy:0x007ffbc3c7cb50 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>
64
+ # That's lazily evaluated try ...
65
+
66
+ user.posts.to_a
67
+ # => [#<struct Post
68
+ # id=1,
69
+ # author=
70
+ # #<struct User
71
+ # id=1,
72
+ # first_name="Stephen",
73
+ # last_name="Best",
74
+ # email="bestie@gmail.com",
75
+ # posts=#<SequelMapper::AssociationProxy:0x007ffbc3c7cb50 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>>,
76
+ # subject="Object mapping",
77
+ # body="It is often tricky",
78
+ # comments=#<SequelMapper::AssociationProxy:0x007ffbc59377b8 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>,
79
+ # categories=#<SequelMapper::AssociationProxy:0x007ffbc5936138 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>>,
80
+ # #<struct Post
81
+ # id=2,
82
+ # author=
83
+ # #<struct User
84
+ # id=1,
85
+ # first_name="Stephen",
86
+ # last_name="Best",
87
+ # email="bestie@gmail.com",
88
+ # posts=#<SequelMapper::AssociationProxy:0x007ffbc3c7cb50 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>>,
89
+ # subject="Object mapping part 2",
90
+ # body="Lazy load all the things!",
91
+ # comments=#<SequelMapper::AssociationProxy:0x007ffbc5935990 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>,
92
+ # categories=#<SequelMapper::AssociationProxy:0x007ffbc592fe50 @assoc_enum=#<Enumerator::Lazy: ...>, @removed_nodes=[]>>]
93
+
94
+ # And then access the comments and so on ...
95
+ ```
96
+
97
+ ## Installation
98
+
99
+ Add this line to your application's Gemfile:
100
+
101
+ ```ruby
102
+ gem 'sequel_mapper'
103
+ ```
104
+
105
+ And then execute:
106
+
107
+ $ bundle
108
+
109
+ Or install it yourself as:
110
+
111
+ $ gem install sequel_mapper
112
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
data/TODO.md ADDED
@@ -0,0 +1,33 @@
1
+ # TODOs
2
+
3
+ In no particular order
4
+
5
+ ## General
6
+ * Refactor, methods too, big objects missing
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
16
+
17
+ ## Querying
18
+ * Querying API, what would a repository with some arbitrary queries look like?
19
+ - e.g. an association on post called `burger_comments` that finds comments
20
+ with the word burger in them
21
+ * Add other querying methods from assocaition proxies or remove entirely
22
+ - Depends on nailing down the querying API
23
+ * When possible optimise blocks given to `AssociationProxy#select` with
24
+ Sequel's `#where` with block [querying API](http://sequel.jeremyevans.net/rdoc/files/doc/cheat_sheet_rdoc.html#label-AND%2FOR%2FNOT)
25
+
26
+ ## Associations
27
+ * Eager loading
28
+ * Read only associations
29
+ - Loaded objects would be immutable
30
+ - Collection proxy would have no #push or #remove
31
+ - Skipped when dumping
32
+ * Associations defined with a join
33
+ * Composable associations
@@ -0,0 +1,4 @@
1
+ module SequelMapper
2
+ end
3
+
4
+ require "sequel_mapper/graph"
@@ -0,0 +1,54 @@
1
+ require "forwardable"
2
+
3
+ module SequelMapper
4
+ class AssociationProxy
5
+ def initialize(assoc_enum)
6
+ @assoc_enum = assoc_enum
7
+ @added_nodes = []
8
+ @removed_nodes = []
9
+ end
10
+
11
+ attr_reader :assoc_enum, :removed_nodes, :added_nodes
12
+ private :assoc_enum
13
+
14
+ include Enumerable
15
+ def each(&block)
16
+ enum = Enumerator.new do |yielder|
17
+ assoc_enum.each do |element|
18
+ yielder.yield(element) unless removed?(element)
19
+ end
20
+
21
+ @added_nodes.each do |node|
22
+ yielder.yield(node)
23
+ end
24
+ end
25
+
26
+ if block
27
+ enum.each(&block)
28
+ self
29
+ else
30
+ enum
31
+ end
32
+ end
33
+
34
+ def remove(node)
35
+ @removed_nodes.push(node)
36
+ self
37
+ end
38
+
39
+ def push(node)
40
+ @added_nodes.push(node)
41
+ end
42
+
43
+ def where(criteria)
44
+ @assoc_enum.where(criteria)
45
+ self
46
+ end
47
+
48
+ private
49
+
50
+ def removed?(node)
51
+ @removed_nodes.include?(node)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ require "delegate"
2
+
3
+ class BelongsToAssociationProxy < SimpleDelegator
4
+ def initialize(object_loader)
5
+ @object_loader = object_loader
6
+ @loaded = false
7
+ end
8
+
9
+ def method_missing(method_id, *args, &block)
10
+ __load_object__
11
+
12
+ super
13
+ end
14
+
15
+ def __getobj__
16
+ __load_object__
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def __load_object__
23
+ __setobj__(@object_loader.call).tap {
24
+ @loaded = true
25
+ } unless @loaded
26
+ end
27
+ end
@@ -0,0 +1,174 @@
1
+ require "sequel_mapper/association_proxy"
2
+ require "sequel_mapper/belongs_to_association_proxy"
3
+ require "sequel_mapper/queryable_association_proxy"
4
+
5
+ module SequelMapper
6
+ class Graph
7
+ def initialize(datastore:, top_level_namespace:, relation_mappings:)
8
+ @top_level_namespace = top_level_namespace
9
+ @datastore = datastore
10
+ @relation_mappings = relation_mappings
11
+ end
12
+
13
+ attr_reader :top_level_namespace, :datastore, :relation_mappings
14
+ private :top_level_namespace, :datastore, :relation_mappings
15
+
16
+ def where(criteria)
17
+ datastore[top_level_namespace]
18
+ .where(criteria)
19
+ .map { |row|
20
+ load(
21
+ relation_mappings.fetch(top_level_namespace),
22
+ row,
23
+ )
24
+ }
25
+ end
26
+
27
+ def save(graph_root)
28
+ @persisted_objects = []
29
+ dump(top_level_namespace, graph_root)
30
+ end
31
+
32
+ private
33
+
34
+ def identity_map
35
+ @identity_map ||= {}
36
+ end
37
+
38
+ def dump(relation_name, object)
39
+ return if @persisted_objects.include?(object)
40
+ @persisted_objects.push(object)
41
+
42
+ relation = relation_mappings.fetch(relation_name)
43
+
44
+ row = object.to_h.select { |field_name, _v|
45
+ relation.fetch(:columns).include?(field_name)
46
+ }
47
+
48
+ relation.fetch(:belongs_to, []).each do |assoc_name, assoc_config|
49
+ row[assoc_config.fetch(:foreign_key)] = object.public_send(assoc_name).id
50
+ end
51
+
52
+ relation.fetch(:has_many, []).each do |assoc_name, assoc_config|
53
+ object.public_send(assoc_name).each do |assoc_object|
54
+ dump(assoc_config.fetch(:relation_name), assoc_object)
55
+ end
56
+
57
+ next unless object.public_send(assoc_name).respond_to?(:removed_nodes)
58
+ object.public_send(assoc_name).removed_nodes.each do |removed_node|
59
+ datastore[assoc_config.fetch(:relation_name)]
60
+ .where(id: removed_node.id)
61
+ .delete
62
+ end
63
+ end
64
+
65
+ relation.fetch(:has_many_through, []).each do |assoc_name, assoc_config|
66
+ object.public_send(assoc_name).each do |assoc_object|
67
+ dump(assoc_config.fetch(:relation_name), assoc_object)
68
+ end
69
+
70
+ next unless object.public_send(assoc_name).respond_to?(:added_nodes)
71
+ object.public_send(assoc_name).added_nodes.each do |added_node|
72
+ datastore[assoc_config.fetch(:through_relation_name)]
73
+ .insert(
74
+ assoc_config.fetch(:foreign_key) => object.id,
75
+ assoc_config.fetch(:association_foreign_key) => added_node.id,
76
+ )
77
+ end
78
+
79
+ object.public_send(assoc_name).removed_nodes.each do |removed_node|
80
+ datastore[assoc_config.fetch(:through_relation_name)]
81
+ .where(assoc_config.fetch(:association_foreign_key) => removed_node.id)
82
+ .delete
83
+ end
84
+ end
85
+
86
+ existing = datastore[relation_name]
87
+ .where(id: object.id)
88
+
89
+ if existing.empty?
90
+ datastore[relation_name].insert(row)
91
+ else
92
+ existing.update(row)
93
+ end
94
+ end
95
+
96
+ def load(relation, row)
97
+ previously_loaded_object = identity_map.fetch(row.fetch(:id), false)
98
+ return previously_loaded_object if previously_loaded_object
99
+
100
+ # puts "****************LOADING #{row.fetch(:id)}"
101
+
102
+ has_many_associations = Hash[
103
+ relation.fetch(:has_many, []).map { |assoc_name, assoc|
104
+ data_enum = datastore[assoc.fetch(:relation_name)]
105
+ .where(assoc.fetch(:foreign_key) => row.fetch(:id))
106
+
107
+ if assoc.fetch(:order_by, false)
108
+ data_enum = data_enum.order(assoc.fetch(:order_by, {}).fetch(:columns, []))
109
+
110
+ if assoc.fetch(:order_by).fetch(:direction, :asc) == :desc
111
+ data_enum = data_enum.reverse
112
+ end
113
+ end
114
+
115
+ [
116
+ assoc_name,
117
+ AssociationProxy.new(
118
+ QueryableAssociationProxy.new(
119
+ data_enum,
120
+ ->(row) {
121
+ load(relation_mappings.fetch(assoc.fetch(:relation_name)), row)
122
+ },
123
+ )
124
+ )
125
+ ]
126
+ }
127
+ ]
128
+
129
+ belongs_to_associations = Hash[
130
+ relation.fetch(:belongs_to, []).map { |assoc_name, assoc|
131
+ [
132
+ assoc_name,
133
+ BelongsToAssociationProxy.new(
134
+ datastore[assoc.fetch(:relation_name)]
135
+ .where(:id => row.fetch(assoc.fetch(:foreign_key)))
136
+ .lazy
137
+ .map { |row|
138
+ load(relation_mappings.fetch(assoc.fetch(:relation_name)), row)
139
+ }
140
+ .public_method(:first)
141
+ )
142
+ ]
143
+ }
144
+ ]
145
+
146
+ has_many_through_assocations = Hash[
147
+ relation.fetch(:has_many_through, []).map { |assoc_name, assoc|
148
+ [
149
+ assoc_name,
150
+ AssociationProxy.new(
151
+ QueryableAssociationProxy.new(
152
+ datastore[assoc.fetch(:relation_name)]
153
+ .join(assoc.fetch(:through_relation_name), assoc.fetch(:association_foreign_key) => :id)
154
+ .where(assoc.fetch(:foreign_key) => row.fetch(:id)),
155
+ ->(row) {
156
+ load(relation_mappings.fetch(assoc.fetch(:relation_name)), row)
157
+ },
158
+ )
159
+ )
160
+ ]
161
+ }
162
+ ]
163
+
164
+ relation.fetch(:factory).call(
165
+ row
166
+ .merge(has_many_associations)
167
+ .merge(has_many_through_assocations)
168
+ .merge(belongs_to_associations)
169
+ ).tap { |object|
170
+ identity_map.store(row.fetch(:id), object)
171
+ }
172
+ end
173
+ end
174
+ end