preserves 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ require "preserves/mapper/relation"
2
+
3
+ module Preserves
4
+ class Mapper
5
+ class BelongsTo < Relation
6
+
7
+ def map!
8
+ assign_attribute(object, relation_name, relation_repo.map_one(relation_result_for_this_object))
9
+ end
10
+
11
+ def relation_result_for_this_object
12
+ @relation_result_for_this_object ||= relation_result_set.find{ |r| r[relation_repo.mapping.primary_key] == record.fetch(relation_foreign_key) }
13
+ end
14
+
15
+ def relation_foreign_key
16
+ @relation_foreign_key ||= relation_settings.fetch(:foreign_key) { "#{relation_name.downcase}_id" }
17
+ end
18
+
19
+ def relation_settings
20
+ @relation_settings ||= mapping.belongs_to_mappings.fetch(relation_name)
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ require "preserves/mapper/relation"
2
+
3
+ module Preserves
4
+ class Mapper
5
+ class HasMany < Relation
6
+
7
+ def map!
8
+ assign_attribute(object, relation_name, relation_repo.map(relation_results_for_this_object))
9
+ end
10
+
11
+ def relation_results_for_this_object
12
+ @relation_results_for_this_object ||= relation_result_set.select{ |r| r[relation_foreign_key] == record.fetch(mapping.primary_key) }
13
+ end
14
+
15
+ def relation_foreign_key
16
+ @relation_foreign_key ||= relation_settings.fetch(:foreign_key) { "#{mapping.model_class.to_s.downcase}_id" }
17
+ end
18
+
19
+ def relation_settings
20
+ @relation_settings ||= mapping.has_many_mappings.fetch(relation_name)
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ module Preserves
2
+ class Mapper
3
+ class Relation
4
+
5
+ attr_reader :object, :record, :relation_name, :relation_result_set, :mapping
6
+
7
+ def initialize(object, record, relation_name, relation_result_set, mapping)
8
+ @object = object
9
+ @record = record
10
+ @relation_name = relation_name
11
+ @relation_result_set = relation_result_set
12
+ @mapping = mapping
13
+ end
14
+
15
+ def relation_repo
16
+ @relation_repo ||= relation_settings.fetch(:repository) # TODO: Need a default.
17
+ end
18
+
19
+ def assign_attribute(object, attribute_name, value)
20
+ object.send("#{attribute_name}=", value)
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,73 @@
1
+ module Preserves
2
+ class Mapping
3
+
4
+ attr_accessor :repository
5
+ attr_accessor :model_class
6
+ attr_accessor :name_mappings
7
+ attr_accessor :type_mappings
8
+ attr_accessor :has_many_mappings
9
+ attr_accessor :belongs_to_mappings
10
+
11
+ def initialize(repository, model_class, &block)
12
+ table_name pluralize(model_class.name.downcase)
13
+ primary_key "id"
14
+ self.repository = repository
15
+ self.model_class = model_class
16
+ self.name_mappings = {}
17
+ self.type_mappings = {}
18
+ self.has_many_mappings = {}
19
+ self.belongs_to_mappings = {}
20
+ self.instance_eval(&block)
21
+ end
22
+
23
+ # Note that this works to set or get the table name.
24
+ # TODO: We don't want to allow publicly setting this, but we need to publicly get it.
25
+ def table_name(name=nil)
26
+ @table_name = name unless name.nil?
27
+ @table_name
28
+ end
29
+
30
+ # Note that this works to set or get the primary key.
31
+ # TODO: We don't want to allow publicly setting this, but we need to publicly get it.
32
+ def primary_key(key_name=nil)
33
+ @primary_key = key_name unless key_name.nil?
34
+ @primary_key
35
+ end
36
+
37
+ protected
38
+
39
+ def map(*args)
40
+ if args[0].is_a?(Hash)
41
+ database_field_name = args[0].values.first
42
+ model_attribute_name = args[0].keys.first
43
+ self.name_mappings[database_field_name] = model_attribute_name
44
+ elsif args[0].is_a?(Symbol)
45
+ model_attribute_name = args[0]
46
+ end
47
+
48
+ if args[1].is_a?(Class)
49
+ self.type_mappings[model_attribute_name] = args[1]
50
+ end
51
+ end
52
+
53
+ def has_many(related_attribute_name, options)
54
+ self.has_many_mappings[related_attribute_name] = options
55
+ end
56
+
57
+ def belongs_to(related_attribute_name, options)
58
+ self.belongs_to_mappings[related_attribute_name] = options
59
+ end
60
+
61
+ def pluralize(string)
62
+ case string
63
+ when /(x|ch|ss|sh)$/i
64
+ string + "es"
65
+ when /s$/i
66
+ string
67
+ else
68
+ string + "s"
69
+ end
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,69 @@
1
+ require "preserves/selection"
2
+ require "preserves/mapping"
3
+ require "preserves/mapper"
4
+
5
+
6
+ module Preserves
7
+ class Repository
8
+
9
+ attr_accessor :model_class
10
+
11
+ def initialize(options={})
12
+ self.model_class = options[:model]
13
+ end
14
+
15
+ def fetch(primary_key_value)
16
+ select(fetch_query, primary_key_value).only
17
+ end
18
+
19
+ def fetch!(primary_key_value)
20
+ select(fetch_query, primary_key_value).only!
21
+ end
22
+
23
+ alias_method :[], :fetch
24
+
25
+ def map(result, relations={})
26
+ mapper.map(result, relations)
27
+ end
28
+
29
+ def map_one(result, relations={})
30
+ mapper.map_one(result, relations)
31
+ end
32
+
33
+ def mapping(&block)
34
+ @mapping ||= Mapping.new(self, model_class, &block)
35
+ end
36
+
37
+ protected
38
+
39
+ def query(sql_string, *params)
40
+ pg_result = data_store.exec_params(sql_string, params)
41
+ SQL::ResultSet.new(pg_result)
42
+ end
43
+
44
+ def select(sql_string, *params)
45
+ if params && params.last.is_a?(Hash)
46
+ relations = params.pop
47
+ else
48
+ relations = {}
49
+ end
50
+ Selection.new(map(query(sql_string, *params), relations))
51
+ end
52
+
53
+ private
54
+
55
+ def mapper
56
+ @mapper ||= Mapper.new(@mapping)
57
+ end
58
+
59
+ # NOTE: We'll allow overriding this default on a per-repository basis later.
60
+ def data_store
61
+ Preserves.data_store
62
+ end
63
+
64
+ def fetch_query
65
+ "SELECT * FROM \"#{mapping.table_name}\" WHERE #{mapping.primary_key} = $1"
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,65 @@
1
+ module Preserves
2
+ class Selection
3
+
4
+ include Enumerable
5
+
6
+ attr_accessor :domain_objects
7
+
8
+ def initialize(domain_objects)
9
+ self.domain_objects = domain_objects
10
+ end
11
+
12
+ def each(&block)
13
+ domain_objects.each(&block)
14
+ end
15
+
16
+ def size
17
+ domain_objects.size
18
+ end
19
+
20
+ def first
21
+ domain_objects.first
22
+ end
23
+
24
+ def first!
25
+ fail "expected at least 1 result" if size == 0
26
+ domain_objects.first
27
+ end
28
+
29
+ def second
30
+ domain_objects.second
31
+ end
32
+
33
+ def second!
34
+ fail "expected at least 1 result" if size == 0
35
+ domain_objects.second
36
+ end
37
+
38
+ def last
39
+ domain_objects.last
40
+ end
41
+
42
+ def last!
43
+ fail "expected at least 1 result" if size == 0
44
+ domain_objects.last
45
+ end
46
+
47
+ def only
48
+ fail "expected only 1 result" if size > 1
49
+ domain_objects.first
50
+ end
51
+
52
+ def only!
53
+ fail "expected exactly 1 result" if size != 1
54
+ domain_objects.first
55
+ end
56
+
57
+ alias_method :one, :only
58
+ alias_method :one!, :only!
59
+
60
+ def [](index)
61
+ domain_objects[index]
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,12 @@
1
+ require "pg"
2
+ require "preserves/sql/result_set"
3
+
4
+
5
+ module Preserves
6
+ module SQL
7
+ def self.connection(*args)
8
+ @connection ||= {}
9
+ @connection[args] ||= PG.connect(*args)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ module Preserves
2
+ module SQL
3
+ class ResultSet
4
+
5
+ include Enumerable
6
+
7
+ def initialize(pg_result)
8
+ @pg_result = pg_result
9
+ end
10
+
11
+ def size
12
+ @pg_result.ntuples == 0 ? @pg_result.cmd_tuples : @pg_result.ntuples
13
+ end
14
+
15
+ def each(&block)
16
+ @pg_result.each(&block)
17
+ end
18
+
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module Preserves
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'preserves/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "preserves"
8
+ spec.version = Preserves::VERSION
9
+ spec.authors = ["Craig Buchek"]
10
+ spec.email = ["craig@boochtek.com"]
11
+ spec.summary = %q{Minimalist ORM, using the Data Mapper pattern}
12
+ spec.description = %q{Experimental, opinionated, minimalist ORM (object-relational mapper) for Ruby, using the Data Mapper pattern.}
13
+ spec.homepage = "https://github.com/boochtek/ruby_preserves"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "pg", "~> 0.18.3"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.10"
24
+ spec.add_development_dependency "rake", "~> 10.4"
25
+ spec.add_development_dependency "rspec", "~> 3.3"
26
+ spec.add_development_dependency "rubygems-tasks", "~> 0.2"
27
+ end
@@ -0,0 +1,260 @@
1
+ require_relative "spec_helper"
2
+ require "preserves"
3
+
4
+ class Group
5
+ attr_accessor :name
6
+ end
7
+
8
+ class User
9
+ attr_accessor :id
10
+ attr_accessor :age
11
+ attr_accessor :addresses
12
+ attr_accessor :group
13
+ end
14
+
15
+ class Address
16
+ attr_accessor :city
17
+ end
18
+
19
+
20
+ AddressRepository = Preserves.repository(model: Address) do
21
+ mapping do
22
+ map :city, String
23
+ end
24
+ end
25
+
26
+ GroupRepository = Preserves.repository(model: Group) do
27
+ mapping do
28
+ map :name, String
29
+ end
30
+ end
31
+
32
+ UserRepository = Preserves.repository(model: User) do
33
+ mapping do
34
+ primary_key 'username'
35
+ map id: 'username'
36
+ map :age, Integer
37
+ has_many :addresses, repository: AddressRepository, foreign_key: 'username'
38
+ belongs_to :group, repository: GroupRepository
39
+ end
40
+ end
41
+
42
+
43
+ describe "Repository" do
44
+
45
+ subject(:repository) { UserRepository }
46
+
47
+ describe "executing a query" do
48
+ let(:query) { repository.send(:query, "INSERT INTO users (username, name, age) VALUES ($1, $2, $3)", 'booch', 'Craig', 43) }
49
+
50
+ # This can't be done with let(), because we don't want to cache it.
51
+ def number_of_rows_in_user_table
52
+ Preserves::SQL.connection(dbname: "preserves_test").exec("SELECT COUNT(*) FROM users")[0]["count"].to_i
53
+ end
54
+
55
+ it "hits the database" do
56
+ expect{ query }.to change{ number_of_rows_in_user_table }
57
+ end
58
+
59
+ it "returns the number of rows processed" do
60
+ expect(query.size).to eq(1)
61
+ end
62
+ end
63
+
64
+ describe "selecting results from a query to an object" do
65
+
66
+ describe "when DB has 0 users" do
67
+ let(:selection) { repository.send(:select, "SELECT username AS id FROM users") }
68
+
69
+ it "works when restricting with `only`" do
70
+ expect(selection.only).to eq(nil)
71
+ end
72
+
73
+ it "raises an exception when restricting with `only!`" do
74
+ expect{ selection.only! }.to raise_exception("expected exactly 1 result")
75
+ end
76
+
77
+ it "raises an exception when restricting with `first!`" do
78
+ expect{ selection.first! }.to raise_exception("expected at least 1 result")
79
+ end
80
+
81
+ it "raises an exception when restricting with `last!`" do
82
+ expect{ selection.last! }.to raise_exception("expected at least 1 result")
83
+ end
84
+
85
+ end
86
+
87
+ describe "when DB has 1 user" do
88
+ before do
89
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
90
+ end
91
+
92
+ let(:selection) { repository.send(:select, "SELECT username AS id FROM users") }
93
+
94
+ it "returns a set of 1 User object" do
95
+ expect(selection.size).to eq(1)
96
+ expect(selection.first.class).to eq(User)
97
+ end
98
+
99
+ it "sets the attributes on the object" do
100
+ expect(selection.first.id).to eq("booch")
101
+ end
102
+
103
+ it "works when restricting with `only`" do
104
+ expect(selection.only.id).to eq("booch")
105
+ end
106
+
107
+ it "works when restricting with `only!`" do
108
+ expect(selection.only!.id).to eq("booch")
109
+ end
110
+
111
+ it "works when restricting with `first!`" do
112
+ expect(selection.first!.id).to eq("booch")
113
+ end
114
+
115
+ it "works when restricting with `last!`" do
116
+ expect(selection.last!.id).to eq("booch")
117
+ end
118
+ end
119
+
120
+ describe "when DB has 2 users" do
121
+
122
+ before do
123
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
124
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('beth', 'Beth', 39)")
125
+ end
126
+
127
+ let(:selection) { repository.send(:select, "SELECT username AS id FROM users") }
128
+
129
+ it "returns a set of 2 User objects" do
130
+ expect(selection.size).to eq(2)
131
+ expect(selection.first.class).to eq(User)
132
+ expect(selection.last.class).to eq(User)
133
+ end
134
+
135
+ it "sets the attributes on the objects" do
136
+ expect(selection.first.id).to eq("booch")
137
+ expect(selection.last.id).to eq("beth")
138
+ end
139
+
140
+ it "raises an exception when restricting with `only`" do
141
+ expect{ selection.only }.to raise_exception("expected only 1 result")
142
+ end
143
+
144
+ it "raises an exception when restricting with `only!`" do
145
+ expect{ selection.only! }.to raise_exception("expected exactly 1 result")
146
+ end
147
+
148
+ it "can fetch the objects by ID" do
149
+ expect(repository.fetch('booch').class).to eq(User)
150
+ expect(repository.fetch('booch').age).to eq(43)
151
+ expect(repository['booch'].class).to eq(User)
152
+ expect(repository['booch'].age).to eq(43)
153
+ expect(repository.fetch!('booch').class).to eq(User)
154
+ expect(repository.fetch!('booch').age).to eq(43)
155
+ expect{ repository.fetch!('unknown') }.to raise_exception("expected exactly 1 result")
156
+ end
157
+ end
158
+
159
+ describe "when mapping a field name to a different model attribute name" do
160
+ before do
161
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
162
+ end
163
+
164
+ let(:selection) { repository.send(:select, "SELECT username FROM users") }
165
+
166
+ it "sets the attribute on the object" do
167
+ expect(selection.first.id).to eq("booch")
168
+ end
169
+ end
170
+
171
+ describe "when mapping a field to an Integer" do
172
+ before do
173
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
174
+ end
175
+
176
+ let(:selection) { repository.send(:select, "SELECT age FROM users") }
177
+
178
+ it "sets the attribute on the object to the right type" do
179
+ expect(selection.first.age).to eq(43)
180
+ end
181
+ end
182
+
183
+ describe "when mapping a field to an Integer" do
184
+ before do
185
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
186
+ end
187
+
188
+ let(:selection) { repository.send(:select, "SELECT age FROM users") }
189
+
190
+ it "sets the attribute on the object to the right type" do
191
+ expect(selection.first.age).to eq(43)
192
+ end
193
+ end
194
+
195
+ describe "when mapping a has_many relation" do
196
+ before do
197
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('booch', 'Craig', 43)")
198
+ repository.send(:query, "INSERT INTO users (username, name, age) VALUES ('beth', 'Beth', 39)")
199
+ repository.send(:query, "INSERT INTO addresses (city, username) VALUES ('Overland', 'booch')")
200
+ repository.send(:query, "INSERT INTO addresses (city, username) VALUES ('Wildwood', 'booch')")
201
+ repository.send(:query, "INSERT INTO addresses (city, username) VALUES ('Ballwin', 'booch')")
202
+ repository.send(:query, "INSERT INTO addresses (city, username) VALUES ('Ballwin', 'beth')")
203
+ repository.send(:query, "INSERT INTO addresses (city, username) VALUES ('Keokuk', 'unknown')")
204
+ end
205
+
206
+ let(:address_query) { repository.send(:query, "SELECT * FROM addresses") }
207
+ let(:selection) { repository.send(:select, "SELECT * FROM users", addresses: address_query) }
208
+
209
+ it "gets the basic fields" do
210
+ expect(selection.first.id).to eq('booch')
211
+ expect(selection.first.age).to eq(43)
212
+ expect(selection.last.id).to eq('beth')
213
+ expect(selection.last.age).to eq(39)
214
+ end
215
+
216
+ it "gets all the related items" do
217
+ expect(selection.first.addresses).to_not be(nil)
218
+ expect(selection.first.addresses.size).to eq(3)
219
+ expect(selection.first.addresses.map(&:city)).to include("Overland")
220
+ expect(selection.first.addresses.map(&:city)).to include("Wildwood")
221
+ expect(selection.first.addresses.map(&:city)).to include("Ballwin")
222
+ expect(selection.last.addresses).to_not be(nil)
223
+ expect(selection.last.addresses.size).to eq(1)
224
+ expect(selection.last.addresses.map(&:city)).to include("Ballwin")
225
+ end
226
+
227
+ end
228
+
229
+ describe "when mapping a belongs_to relation" do
230
+ before do
231
+ repository.send(:query, "INSERT INTO groups (id, name) VALUES (1, 'admin')")
232
+ repository.send(:query, "INSERT INTO groups (id, name) VALUES (2, 'users')")
233
+ repository.send(:query, "INSERT INTO users (username, name, age, group_id) VALUES ('booch', 'Craig', 43, 1)")
234
+ repository.send(:query, "INSERT INTO users (username, name, age, group_id) VALUES ('beth', 'Beth', 39, 2)")
235
+ end
236
+
237
+ let(:group_query) { repository.send(:query, "SELECT * FROM groups") }
238
+ let(:selection) { repository.send(:select, "SELECT * FROM users", group: group_query) }
239
+
240
+ it "gets the basic fields" do
241
+ expect(selection.first.id).to eq('booch')
242
+ expect(selection.first.age).to eq(43)
243
+ expect(selection.last.id).to eq('beth')
244
+ expect(selection.last.age).to eq(39)
245
+ end
246
+
247
+ it "gets all the related items" do
248
+ expect(selection.first.group).to_not be(nil)
249
+ expect(selection.first.group).to be_a(Group)
250
+ expect(selection.first.group.name).to eq("admin")
251
+ expect(selection.last.group).to_not be(nil)
252
+ expect(selection.last.group).to be_a(Group)
253
+ expect(selection.last.group.name).to eq("users")
254
+ end
255
+
256
+ end
257
+
258
+ end
259
+
260
+ end