sequel_mapper 0.0.1

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