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