Accompanying code for https://rubberduckvba.wordpress.com/2020/04/22/secure-adodb/
A thin wrapper around the ADODB
library, automating the creating of ADODB.Connection
, ADODB.Command
, ADODB.Parameter
objects to securely communicate with a database using VBA. The IDbConnection
is a leaky abstraction on purpose, to support more advanced scenarios and retain the full functionality of ADODB, while providing a simple-to-use API for the most common scenarios.
This code uses early-bound reference to the ADODB
library: the project where this code is imported must have a reference Microsoft ActiveX Data Objects. Alternatively, the ADODB-specific code could be modified to use late binding instead (pull requests welcome!), and then the project could be compiled without adding such a reference.
Rubberduck is not required to view the code, but using the Code Explorer toolwindow will make the structure much more apparent and easier to navigate:
Rubberduck is required for running the unit tests, but the modules under the Test
folder (standard modules with a @TestModule
annotation comment, and the StubXxxxx
class modules too) are not needed for the code to be usable. Recommendation is to include the unit tests if you're planning to modify the code in any way.
The top-level API object is UnitOfWork
and the IUnitOfWork
interface. Given a connString
connection string, and a sql
command string, the simplest way to use it is to start with the UnitOfWork.FromConnectionString
factory method - using a With
block, we don't even need to declare the IUnitOfWork
object:
With UnitOfWork.FromConnectionString(connString)
'connection is open, a transaction is initiated.
'IDbCommand.Execute returns a disconnected ADODB.Recordset:
Dim results As ADODB.Recordset
'simply use '?' ordinal parameters in the command string, and then provide a value for each '?' in the SQL:
Set results = .Command.Execute("SELECT * FROM Table1 WHERE Field1 = ? AND Field2 = ?", 42, "Test")
'if we don't need a recordset, we can use ExecuteNonQuery too:
.Command.ExecuteNonQuery "INSERT INTO Table1 (DateInserted, Field1, Field2) VALUES (GETDATE(), ?, ?)", 42, "TEST"
'if we only want to select a single value, we can use GetSingleValue:
Dim result As Long
result = .Command.GetSingleValue("SELECT Field2 FROM Table1 WHERE Field1 = ?", 42)
'we are in a transaction, so we need to commit the changes - lest we lose them:
.Commit '<~ make sure to only commit AT MOST ONCE per transaction.
End With 'transaction is rolled back if not committed, connection is closed.
When working with an IUnitOfWork
, best practices would be:
- DO hold the object reference in a
With
block. (e.g.With New UnitOfWork.FromConnectionString(...)
) - DO have an active
On Error
statement to graciously handle any errors. - DO commit or rollback the transaction explicitly in the scope that owns the
IUnitOfWork
object. - AVOID passing
IUnitOfWork
as a parameter to another object or procedure. - AVOID accidentally re-entering a
With
block from an error-handling subroutine (i.e. avoidResume
orResume Next
). If there was an error, execution jumped out of theWith
block that held the references, and the transaction is rolled back and the connection is closed. There's nothing left to clean up.
The scope that creates the IUnitOfWork
is responsible for knowing what to do with the transaction it encapsulates: we absolutely will be calling other code, but that other code very likely only needs the IDbCommand
interface, not the whole transaction.
A unit of work can only be committed or rolled back (one of) once: keep that responsibility in the scope that owns the IUnitOfWork
.
If a transaction isn't needed (recommendation: use one anyway), the API provides the flexibility to use an IDbConnection
directly. The objects involved are exactly the same as with an IUnitOfWork
, except now we're going to be creating and injecting them manually (the unit tests demonstrate this).
With DbConnection.Create(connString)
'connection is open (no transaction is initiated, but invoking .BeginTransaction would do that)
Dim conn As ADODB.Connection
Set conn = .AdoConnection '<~ if we want we can access the wrapped connection directly from here, but we don't need to.
'...
End With 'connection is closed.
When working with an IDbConnection
, best practices would be:
- DO hold the object reference in a
With
block. (e.g.With DbConnection.Create(...)
) - DO have an active
On Error
statement to graciously handle any errors. - CONSIDER passing the
IDbConnection
object toUnitOfWork.Create
. - CONSIDER passing the
IDbConnection
object toDefaultDbCommand.Create
. - AVOID passing
IDbConnection
as a parameter to another object or procedure.
If an error occurs and execution jumps out of the With
block (and the With
block is holding the IDbConnection
reference), then the connection is already closed when the error handler gets to run.
Using an IUnitOfWork
automatically wires up a factory (DefaultDbCommandFactory
) that automatically creates these objects for us, but if we want complete flexibility while still leveraging the API, we can wire up the pieces ourselves with classes implementing the IDbCommand
interface.
There are two implementations:
AutoDbCommand
takes anIDbConnectionFactory
, owns its database connection, and outputs disconnected recordsets. It also requires anIDbCommandBase
implementation to be supplied to itsCreate
factory method. This class is intended to be used without aUnitOfWork
.DefaultDbCommand
takes anIDbConnection
andIDbCommandBase
dependencies, and does not own the connection it works with: that's useful when different instances/commands need to run in the same transaction;UnitOfWork
uses this implementation.
To use this implementation of IDbCommand
, you need to inject an abstract factory that's responsible for creating database connections, as well as an IDbCommandBase
object, which is ultimately responsible for wiring up all the components needed to issue a parameterized ADODB command.
The abstract factory is implemented by the DbConnectionFactory
class, but the IDbCommandBase
object needs its own dependencies injected - that's the IParameterProvider
, an object that's responsible for creating ADODB.Parameter
objects out of user-provided values:
Dim mappings As ITypeMap
Set mappings = AdoTypeMappings.Default
'if we want to tweak the ADODB parameter types associated with an intrinsic VBA data type, we can:
''mappings.Mapping("Date") = adVarChar
Dim provider As IParameterProvider
Set provider = AdoParameterProvider.Create(mappings)
Dim baseCommand As IDbCommandBase
Set baseCommand = DbCommandBase.Create(provider)
Dim factory As IDbConnectionFactory
Set factory = New DbConnectionFactory 'the only other implementation is StubDbConnectionFactory, for unit tests.
Dim cmd As IDbCommand
Set cmd = AutoDbCommand.Create(connString, factory, baseCommand)
Dim results As ADODB.Recordset
'simply use '?' ordinal parameters in the command string, and then provide a value for each '?' in the SQL:
Set results = cmd.Execute("SELECT * FROM Table1 WHERE Field1 = ? AND Field2 = ?", 42, "Test")
When using an AutoDbCommand
, we don't need to worry about the database connection: the object uses the supplied factory to create it with the connection string we provide - the command object can be reused for multiple successive calls to various methods, but keep in mind that a new database connection will be created every time.
When more than a single command needs to be sent to a database, it's usually best to run them all using the same connection: you would use a DefaultDbCommand
for that. The setup is similar to that of AutoDbCommand
, except no connection factory is involved now - instead, we are working with an IDbConnection
:
Dim mappings As ITypeMap
Set mappings = AdoTypeMappings.Default
'if we want to tweak the ADODB parameter types associated with an intrinsic VBA data type, we can:
''mappings.Mapping("Date") = adVarChar
Dim provider As IParameterProvider
Set provider = AdoParameterProvider.Create(mappings)
Dim baseCommand As IDbCommandBase
Set baseCommand = DbCommandBase.Create(provider)
With DbConnection.Create(connString)
Dim cmd As IDbCommand
Set cmd = DefaultDbCommand.Create(.Self, baseCommand)
Dim results As ADODB.Recordset
'simply use '?' ordinal parameters in the command string, and then provide a value for each '?' in the SQL:
Set results = cmd.Execute("SELECT * FROM Table1 WHERE Field1 = ? AND Field2 = ?", 42, "Test")
'...
End With
This already covers most scenarios, but there's more!
Mapping VBA intrinsic data types to ADODB.DataTypeEnum
values works fine, ...until you need to supply a null
value, or supply an output parameter to a stored procedure. In such scenarios, you will need to crack the abstraction open and use the underlying ADODB API.
To create an ADODB.Parameter
with a NULL
value, you can use the dataTypeName
optional parameter of the IParameterProvider.FromValue
method to supply the name of the VBA intrinsic data type. The type of the parameter will be as per the mappings for that intrinsic data type:
Dim provider As IParameterProvider
Set provider = AdoParameterProvider.Create(mappings)
Dim nullStringValue As ADODB.Parameter
Set nullStringValue = provider.FromValue(Null, dataTypeName:="String") '<~ with default mappings, that's a NULL adVarWChar parameter.
The ADODB parameters can then be supplied to the IDbCommand.ExecuteWithParameters
method, which will append them to the internal ADODB command:
Dim cmd As IDbCommand
Set cmd = DefaultDbCommand.Create(db, baseCommand)
Dim results As ADODB.Recordset
Set results = cmd.ExecuteWithParameters("SELECT * FROM Table1 WHERE Field1 = ISNULL(?, Field1)", nullStringValue)
Note that the SQL command string must be written in such a way that a NULL
parameter value is still valid a SQL statement: keep in mind that in SQL a WHERE
clause would use the IS
operator to determine whether a value is NULL
.
The ADODB parameters created by the AdoParameterProvider
can be modified after they are created and before they are used in a command:
Dim provider As IParameterProvider
Set provider = AdoParameterProvider.Create(mappings)
Dim outParam As ADODB.Parameter
Set outParam = provider.FromValue(42)
outParam.Direction = adParamOutput
The modified parameter(s) can then be supplied to IDbCommand.ExecuteWithParameters
to execute a stored procedure taking output parameters.
There is no direct support for stored procedures for now, because Execute
and ExecuteNonQuery
cover these grounds already, at least as far as SQL Server is concerned - given an exec
statement:
Const sql As String = "exec SomeStoredProcedure ?, ?"
AutoDbCommand.Create(connString, New DbConnectionFactory, baseCommand).ExecuteNonQuery sql, 42, "test"
That said the IDbCommand
interface could very well be modified to expose an ExecuteStoredProcedure
method that takes the name of a stored procedure and its arguments, and wires up an adCmdStoredProc
ADODB command.