db_mod 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,235 @@
1
+ require_relative 'params'
2
+
3
+ module DbMod
4
+ module Statements
5
+ # Provides the +def_prepared+ function which allows
6
+ # {DbMod} modules to declare prepared SQL statements
7
+ # that will be added to the database connection when
8
+ # {DbMod#db_connect} is called.
9
+ #
10
+ # For statements that are not prepared ahead of execution,
11
+ # see +def_statement+ in {DbMod::Statements::Statement}.
12
+ #
13
+ # def_prepared
14
+ # ------------
15
+ #
16
+ # +def_prepared+ accepts two parameters:
17
+ # * `name` [Symbol]: The name that will be given to
18
+ # the prepared statement. A method will also be defined
19
+ # on the module with the same name which will call the
20
+ # statement and return the result.
21
+ # * `sql` [String]: The SQL statement to be prepared.
22
+ # Parameters may be declared using the $ symbol followed
23
+ # by a number ($1, $2, $3) or a name ($one, $two, $under_scores).
24
+ # The two styles may not be mixed in the same statement.
25
+ # The defined function can then be passed parameters
26
+ # that will be used when the statement is executed.
27
+ #
28
+ # ### example
29
+ #
30
+ # module MyModule
31
+ # include DbMod
32
+ #
33
+ # def_prepared :my_prepared, <<-SQL
34
+ # SELECT *
35
+ # FROM stuff
36
+ # WHERE a = $1 AND b = $2
37
+ # SQL
38
+ #
39
+ # def_prepared :my_named_prepared, <<-SQL
40
+ # SELECT *
41
+ # FROM stuff
42
+ # WHERE a = $a AND b = $b
43
+ # SQL
44
+ # end
45
+ #
46
+ # include MyModule
47
+ # db_connect db: 'mydb'
48
+ # my_prepared(1,2)
49
+ # my_named_prepared(a: 1, b: 2)
50
+ module Prepared
51
+ include Params
52
+
53
+ # Defines a module-specific +def_prepared+ function
54
+ # for a module that has just had {DbMod} included.
55
+ #
56
+ # @param mod [Module]
57
+ def self.setup(mod)
58
+ Prepared.define_def_prepared(mod)
59
+ Prepared.define_prepared_statements(mod)
60
+ Prepared.define_inherited_prepared_statements(mod)
61
+ Prepared.define_prepare_all_statements(mod)
62
+ end
63
+
64
+ private
65
+
66
+ # Merge the prepared statements from a module
67
+ # into a given hash. Fails if there are any
68
+ # duplicates.
69
+ #
70
+ # @param statements [Hash] named list of prepared statements
71
+ # @param klass [Class,Module] ancestor (hopefully a DbMod module)
72
+ # to collect prepared statements from
73
+ def self.merge_statements(statements, klass)
74
+ return unless klass.respond_to? :prepared_statements
75
+ return if klass.prepared_statements.nil?
76
+
77
+ klass.prepared_statements.each do |name, sql|
78
+ fail DbMod::Exceptions::DuplicateStatementName if statements.key? name
79
+
80
+ statements[name] = sql
81
+ end
82
+ end
83
+
84
+ # Add a +def_prepared+ method definition to a module.
85
+ # This method allows modules to declare named SQL statements
86
+ # that will be prepared when the database connection is
87
+ # established, and that can be accessed via an instance
88
+ # method with the same name.
89
+ #
90
+ # @param mod [Module] a module with {DbMod} included
91
+ def self.define_def_prepared(mod)
92
+ mod.class.instance_eval do
93
+ define_method(:def_prepared) do |name, sql|
94
+ sql = sql.dup
95
+ name = name.to_sym
96
+
97
+ params = Params.parse_params! sql
98
+ prepared_statements[name] = sql
99
+ Prepared.define_prepared_method(mod, name, params)
100
+ end
101
+ end
102
+ end
103
+
104
+ # Defines +prepare_all_statements+, a module method which
105
+ # accepts a connection object and will prepare on it all of
106
+ # the prepared statements that have been declared on the
107
+ # module or any of its included modules.
108
+ #
109
+ # @param mod [Module] module that has {DbMod} included
110
+ def self.define_prepare_all_statements(mod)
111
+ mod.class.instance_eval do
112
+ define_method(:prepare_all_statements) do |conn|
113
+ inherited_prepared_statements.each do |name, sql|
114
+ conn.prepare(name.to_s, sql)
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ # Define a method in the module with the given name
121
+ # and parameters, that will call the prepared statement
122
+ # with the same name.
123
+ #
124
+ # @param mod [Module] module declaring the metho
125
+ # @param name [Symbol] method name
126
+ # @param params [Fixnum,Array<Symbol>]
127
+ # expected parameter count, or a list of argument names.
128
+ # An empty array produces a no-argument method.
129
+ def self.define_prepared_method(mod, name, params)
130
+ mod.expected_prepared_statement_parameters[name] = params
131
+
132
+ if params.is_a?(Array)
133
+ if params.empty?
134
+ define_no_args_prepared_method(mod, name)
135
+ else
136
+ define_named_args_prepared_method(mod, name, params)
137
+ end
138
+ else
139
+ define_fixed_args_prepared_method(mod, name, params)
140
+ end
141
+ end
142
+
143
+ # Define a no-argument method with the given name
144
+ # that will call the prepared statement with the
145
+ # same name.
146
+ #
147
+ # @param mod [Module] {DbMod} enabled module
148
+ # where the method will be defined
149
+ # @param name [Symbol] name of the method to be defined
150
+ # and the prepared query to be called.
151
+ def self.define_no_args_prepared_method(mod, name)
152
+ mod.instance_eval do
153
+ define_method name, ->() { conn.exec_prepared(name.to_s) }
154
+ end
155
+ end
156
+
157
+ # Define a method with the given name that accepts the
158
+ # given set of named parameters, that will call the prepared
159
+ # statement with the same name.
160
+ #
161
+ # @param mod [Module] {DbMod} enabled module
162
+ # where the method will be defined
163
+ # @param name [Symbol] name of the method to be defined
164
+ # and the prepared query to be called.
165
+ # @param params [Array<Symbol>] list of parameter names
166
+ def self.define_named_args_prepared_method(mod, name, params)
167
+ method = lambda do |*args|
168
+ unless args.size == 1
169
+ fail ArgumentError, "unexpected arguments: #{args.inspect}"
170
+ end
171
+ args = Params.valid_named_args! params, args.first
172
+ conn.exec_prepared(name.to_s, args)
173
+ end
174
+
175
+ mod.instance_eval { define_method(name, method) }
176
+ end
177
+
178
+ # Define a method with the given name that accepts a fixed
179
+ # number of arguments, that will call the prepared statement
180
+ # with the same name.
181
+ #
182
+ # @param mod [Module] {DbMod} enabled module
183
+ # where the method will be defined
184
+ # @param name [Symbol] name of the method to be defined
185
+ # and the prepared query to be called.
186
+ # @param count [Fixnum] arity of the defined method,
187
+ # the number of parameters that the prepared statement
188
+ # requires
189
+ def self.define_fixed_args_prepared_method(mod, name, count)
190
+ method = lambda do |*args|
191
+ unless args.size == count
192
+ fail ArgumentError, "#{args.size} args given, #{count} expected"
193
+ end
194
+
195
+ conn.exec_prepared(name.to_s, args)
196
+ end
197
+
198
+ mod.instance_eval { define_method(name, method) }
199
+ end
200
+
201
+ # Adds +prepared_statements+ to a module. This list of named
202
+ # prepared statements will be added to the connection when
203
+ # {DbMod#db_connect} is called.
204
+ #
205
+ # @param mod [Module]
206
+ def self.define_prepared_statements(mod)
207
+ mod.class.instance_eval do
208
+ define_method(:prepared_statements) do
209
+ @prepared_statements ||= {}
210
+ end
211
+
212
+ define_method(:expected_prepared_statement_parameters) do
213
+ @expected_prepared_statement_parameters ||= {}
214
+ end
215
+ end
216
+ end
217
+
218
+ # Adds +inherited_prepared_statements+ to a module. This list
219
+ # of named prepared statements declared on this module and all
220
+ # included modules will be added to the connection when
221
+ # {DbMod#db_connect} is called.
222
+ def self.define_inherited_prepared_statements(mod)
223
+ mod.class.instance_eval do
224
+ define_method(:inherited_prepared_statements) do
225
+ inherited = {}
226
+ ancestors.each do |klass|
227
+ Prepared.merge_statements(inherited, klass)
228
+ end
229
+ inherited
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,29 @@
1
+ module DbMod
2
+ module Statements
3
+ # Provides the +def_statement+ function which allows
4
+ # {DbMod} modules to declare SQL statements that can
5
+ # then be executed later using a specially defined
6
+ # instance method.
7
+ #
8
+ # To declare prepared statements, see +def_prepared+
9
+ # in {DbMod::Statements::Prepared}.
10
+ #
11
+ # def_statement
12
+ # -------------
13
+ #
14
+ # +def_statement+ accepts two parameters:
15
+ # * `name` [Symbol]: The name that will be given to the
16
+ # method that can be used to execute the SQL statement
17
+ # and return the result.
18
+ # * `sql` [String]: The SQL statement that shoul be executed
19
+ # when the method is called. Parameters may be declared
20
+ # using the $ symbol followed by a number ($1, $2, $3) or
21
+ # a name ($one, $two, $under_scores). The two styles may
22
+ # not be mixed in the same statement. The defined function
23
+ # can then be passed parameters that will be used to fill
24
+ # in the statement before execution.
25
+ module Statement
26
+ # Not yet implemented.
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'statements/statement'
2
+ require_relative 'statements/prepared'
3
+
4
+ module DbMod
5
+ # Functions allowing {DbMod} modules to declare
6
+ # SQL statements that can be called later via
7
+ # automatically declared instance methods.
8
+ #
9
+ # See {DbMod::Statements::Statement} for details on +def_statement+
10
+ # and {DbMod::Statements::Prepared} for details on +def_prepared+.
11
+ module Statements
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ module DbMod
2
+ # Module which provides transaction blocks for db_mod
3
+ # enabled classes.
4
+ module Transaction
5
+ protected
6
+
7
+ # Create a transaction on the db_mod database connection.
8
+ # Calls +BEGIN+ then yields to the given block. Calls
9
+ # +COMMIT+ once the block yields, or +ROLLBACK+ if the
10
+ # block raises an exception.
11
+ #
12
+ # Not thread safe. May not be called from inside another
13
+ # transaction.
14
+ # @return [Object] the result of +yield+
15
+ def transaction
16
+ start_transaction!
17
+
18
+ result = yield
19
+
20
+ query 'COMMIT'
21
+
22
+ result
23
+ rescue
24
+ query 'ROLLBACK'
25
+ raise
26
+
27
+ ensure
28
+ end_transaction!
29
+ end
30
+
31
+ private
32
+
33
+ # Start the database transaction, or fail if
34
+ # one is already open.
35
+ def start_transaction!
36
+ fail DbMod::Exceptions::AlreadyInTransaction if @in_transaction
37
+ @in_transaction = true
38
+
39
+ query 'BEGIN'
40
+ end
41
+
42
+ # End the database transaction
43
+ def end_transaction!
44
+ @in_transaction = false
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # Version information
2
+ module DbMod
3
+ # The current version of db_mod.
4
+ VERSION = '0.0.1'
5
+ end
data/lib/db_mod.rb ADDED
@@ -0,0 +1,87 @@
1
+ require 'pg'
2
+ require_relative 'db_mod/exceptions'
3
+ require_relative 'db_mod/transaction'
4
+ require_relative 'db_mod/create'
5
+ require_relative 'db_mod/statements'
6
+
7
+ # This is the foundation module for enabling db_mod
8
+ # support in an application. Including this module
9
+ # will give your class or object the protected methods
10
+ # {#db_connect} and {#conn=}, allowing the connection
11
+ # to be set or created, as well as the methods {#conn},
12
+ # {#query}, {#transaction}, and {#def_prepared}.
13
+ module DbMod
14
+ include Transaction
15
+
16
+ # When a module includes {DbMod}, we define some
17
+ # class-level functions specific to the module.
18
+ def self.included(mod)
19
+ DbMod::Create.setup(mod)
20
+ DbMod::Statements::Prepared.setup(mod)
21
+ end
22
+
23
+ protected
24
+
25
+ # Database object to be used for all database
26
+ # interactions in this module.
27
+ # Use {#db_connect} to initialize the object.
28
+ attr_accessor :conn
29
+
30
+ # Shorthand for +conn.query+
31
+ def query(sql)
32
+ unless @conn
33
+ fail DbMod::Exceptions::ConnectionNotSet, 'db_connect not called'
34
+ end
35
+ conn.query(sql)
36
+ end
37
+
38
+ # Create a new database connection to be used
39
+ # for all database interactions in this module.
40
+ #
41
+ # @param options [Hash] database connection options
42
+ # @option options [String] :db
43
+ # the name of the database to connect to
44
+ # @option options [String] :host
45
+ # the host server for the database. If not supplied a local
46
+ # posix socket connection will be attempted.
47
+ # @option options [Fixnum] :port
48
+ # port number the database server is listening on. Default is 5432.
49
+ # @option options [String] :user
50
+ # username for database authentication. If not supplied the
51
+ # name of the user running the script will be used (i.e. ENV['USER'])
52
+ # @option options [String] :pass
53
+ # password for database authentication. If not supplied then
54
+ # trusted authentication will be attempted.
55
+ def db_connect(options = {})
56
+ db_defaults! options
57
+ @conn = db_connect! options
58
+ self.class.prepare_all_statements(@conn)
59
+ end
60
+
61
+ private
62
+
63
+ # Load any missing options from defaults
64
+ #
65
+ # @param options [Hash] see {#db_connect}
66
+ def db_defaults!(options)
67
+ fail ArgumentError, 'database name :db not supplied' unless options[:db]
68
+ options[:port] ||= 5432
69
+ options[:user] ||= ENV['USER']
70
+ options[:pass] ||= 'trusted?'
71
+ end
72
+
73
+ # Create the database object itself.
74
+ #
75
+ # @param options [Hash] see {#db_connect}
76
+ def db_connect!(options)
77
+ PGconn.connect(
78
+ options[:host],
79
+ options[:port],
80
+ '',
81
+ '',
82
+ options[:db],
83
+ options[:user],
84
+ options[:pass]
85
+ )
86
+ end
87
+ end
@@ -0,0 +1,39 @@
1
+ require 'spec_helper'
2
+
3
+ module CreateTest
4
+ include DbMod
5
+
6
+ def do_thing
7
+ query 'SELECT 1'
8
+ end
9
+ end
10
+
11
+ describe DbMod::Create do
12
+ before do
13
+ @conn = instance_double 'PGconn'
14
+ allow(PGconn).to receive(:connect).and_return(@conn)
15
+ end
16
+
17
+ it 'creates module instances' do
18
+ expect(PGconn).to receive(:connect).with(
19
+ nil, 5432, '', '', 'testdb', ENV['USER'], 'trusted?'
20
+ )
21
+
22
+ test = CreateTest.create db: 'testdb'
23
+
24
+ expect(@conn).to receive(:query).with('SELECT 1')
25
+
26
+ test.do_thing
27
+ end
28
+
29
+ it 'can be supplied an existing connection' do
30
+ expect(PGconn).not_to receive(:connect)
31
+ expect(@conn).to receive(:is_a?).with(PGconn).and_return true
32
+
33
+ test = CreateTest.create @conn
34
+
35
+ expect(@conn).to receive(:query).with('SELECT 1')
36
+
37
+ test.do_thing
38
+ end
39
+ end
@@ -0,0 +1,155 @@
1
+ require 'spec_helper'
2
+
3
+ describe DbMod::Statements::Prepared do
4
+ subject do
5
+ Module.new do
6
+ include DbMod
7
+
8
+ def_prepared :one, <<-SQL
9
+ SELECT *
10
+ FROM foo
11
+ WHERE a = $1 AND b = $2 AND c = $1
12
+ SQL
13
+
14
+ def_prepared :two, <<-SQL
15
+ SELECT *
16
+ FROM foo
17
+ WHERE a = $a AND b = $b AND c = $a
18
+ SQL
19
+ end
20
+ end
21
+
22
+ before do
23
+ @conn = instance_double 'PGconn'
24
+ allow(PGconn).to receive(:connect).and_return(@conn)
25
+ allow(@conn).to receive(:prepare)
26
+ end
27
+
28
+ it 'adds statements when the connection is created' do
29
+ expect(@conn).to receive(:prepare) do |name, sql|
30
+ expect(%w(one two)).to include name
31
+ expect(sql.split(/\s+/m).join(' ').strip).to eq(
32
+ 'SELECT * FROM foo WHERE a = $1 AND b = $2 AND c = $1'
33
+ )
34
+ end
35
+
36
+ subject.create db: 'testdb'
37
+ end
38
+
39
+ it 'executes statements with numbered params' do
40
+ db = subject.create db: 'testdb'
41
+
42
+ expect(@conn).to receive(:exec_prepared).with('one', [1, 'two'])
43
+ db.one(1, 'two')
44
+
45
+ expect { db.one 'too', 'many', 'args' }.to raise_exception ArgumentError
46
+ end
47
+
48
+ it 'executes statements with named params' do
49
+ db = subject.create db: 'testdb'
50
+
51
+ expect(@conn).to receive(:exec_prepared).with('two', [2, 'three'])
52
+ db.two(b: 'three', a: 2)
53
+
54
+ expect { db.two bad: 'arg', b: 1, a: 1 }.to raise_exception ArgumentError
55
+ expect { db.two b: 'a missing' }.to raise_exception ArgumentError
56
+ expect { db.two 1, 2 }.to raise_exception ArgumentError
57
+ expect { db.two 1 }.to raise_exception ArgumentError
58
+ end
59
+
60
+ it 'prepares and exposes inherited prepared statements' do
61
+ mod = subject
62
+ sub_module = Module.new do
63
+ include mod
64
+
65
+ def_prepared :three, <<-SQL
66
+ SELECT *
67
+ FROM foo
68
+ WHERE a = $1 OR b = $2 OR c = $1
69
+ SQL
70
+ end
71
+
72
+ expect(@conn).to receive(:prepare).exactly(3).times do |name, _|
73
+ expect(%w(one two three)).to include name
74
+ end
75
+
76
+ sub_module.create db: 'testdb'
77
+ end
78
+
79
+ it 'does not allow mixed parameter types' do
80
+ expect do
81
+ Module.new do
82
+ include DbMod
83
+
84
+ def_prepared :numbers_and_names, <<-SQL
85
+ SELECT *
86
+ FROM foo
87
+ WHERE this = $1 AND wont = $work
88
+ SQL
89
+ end
90
+ end.to raise_exception ArgumentError
91
+ end
92
+
93
+ it 'allows no parameters' do
94
+ expect do
95
+ mod = Module.new do
96
+ include DbMod
97
+
98
+ def_prepared :no_params, 'SELECT 1'
99
+ end
100
+
101
+ expect(@conn).to receive(:prepare).with('no_params', 'SELECT 1')
102
+ expect(@conn).to receive(:exec_prepared).with('no_params')
103
+
104
+ db = mod.create(db: 'testdb')
105
+ db.no_params
106
+
107
+ expect { db.no_params(1) }.to raise_exception ArgumentError
108
+ end.not_to raise_exception
109
+ end
110
+
111
+ it 'does not allow invalid parameters' do
112
+ %w(CAPITALS numb3rs_and_l3tt3rs).each do |param|
113
+ expect do
114
+ Module.new do
115
+ include DbMod
116
+
117
+ def_prepared :bad_params, %(
118
+ SELECT * FROM foo where bad = $#{param}
119
+ )
120
+ end
121
+ end.to raise_exception ArgumentError
122
+ end
123
+ end
124
+
125
+ it 'does not allow duplicate statement names' do
126
+ mod = subject
127
+ sub_module = Module.new do
128
+ include mod
129
+
130
+ def_prepared :one, <<-SQL
131
+ SELECT not FROM gonna WHERE work = $1
132
+ SQL
133
+ end
134
+
135
+ expect { sub_module.create db: 'testdb' }.to raise_exception(
136
+ DbMod::Exceptions::DuplicateStatementName
137
+ )
138
+ end
139
+
140
+ it 'validates numbered arguments' do
141
+ [
142
+ 'a = $1 AND b = $2 AND c = $2 AND c = $4',
143
+ 'a = $2 AND b = $2 AND c = $3',
144
+ 'a = $1 AND b = $2 AND c = $4'
145
+ ].each do |params|
146
+ expect do
147
+ Module.new do
148
+ include DbMod
149
+
150
+ def_prepared :bad_params, "SELECT * FROM foo WHERE #{params}"
151
+ end
152
+ end.to raise_exception ArgumentError
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ class TransactionTest
4
+ include DbMod
5
+
6
+ def connect
7
+ db_connect db: 'testdb'
8
+ end
9
+
10
+ def transaction!
11
+ transaction do
12
+ query 'SELECT 1'
13
+ end
14
+ end
15
+
16
+ def nested
17
+ transaction do
18
+ transaction do
19
+ # Not reached
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ describe DbMod::Transaction do
26
+ subject { TransactionTest.new }
27
+
28
+ before do
29
+ @conn = instance_double 'PGconn'
30
+ allow(@conn).to receive(:query)
31
+ allow(subject).to receive(:db_connect!).and_return @conn
32
+ end
33
+
34
+ describe '#transaction' do
35
+ it 'expects the connection to be set' do
36
+ expect { subject.transaction! }.to raise_exception(
37
+ DbMod::Exceptions::ConnectionNotSet
38
+ )
39
+ end
40
+
41
+ it 'calls BEGIN and COMMIT' do
42
+ subject.connect
43
+
44
+ expect(@conn).to receive(:query).with 'BEGIN'
45
+ expect(@conn).to receive(:query).with 'SELECT 1'
46
+ expect(@conn).to receive(:query).with 'COMMIT'
47
+
48
+ subject.transaction!
49
+ end
50
+
51
+ it 'calls ROLLBACK if there is a failure' do
52
+ subject.connect
53
+
54
+ expect(@conn).to receive(:query).with 'BEGIN'
55
+ expect(@conn).to receive(:query).with('SELECT 1').and_raise 'error'
56
+ expect(@conn).to receive(:query).with 'ROLLBACK'
57
+
58
+ expect { subject.transaction! }.to raise_exception 'error'
59
+ end
60
+
61
+ it 'guards against concurrent transactions' do
62
+ subject.connect
63
+
64
+ expect { subject.nested }.to raise_exception(
65
+ DbMod::Exceptions::AlreadyInTransaction
66
+ )
67
+ end
68
+ end
69
+ end