preserves 0.1.0

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.
@@ -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