2
\$\begingroup\$

I built a workbook to help me extract data from a MSSQL database into Excel. I realize that this is fairly easy to do with Microsoft SQL Server Management Studio, but I can't easily get that installed on my machine at work, so I first have to remote to the server every time to get any data, which becomes a mission if you have to do it often.

The workbook has a small control sheet as follows: Workbook Control Sheet

The button calls Sub ExecSQL() which will scan this sheet for SQL statements, execute them and then paste the results into the corresponding sheet. In this example, it will execute one SELECT statement and paste the result into Fct201712.

I know about SQL injection, but given that I won't be giving this out to users and the SQL auths I use anyway do not have write access to the database, I'm not too concerned about that.

I'm trying to implement some of the suggestions I received on this previous question. Given how I now handle errors, I am not closing the DB connection if I get an error. How can I elegantly structure my code so that I always close the connection even if I get an error without using GoTo?

Any other advice on how to improve this code will be greatly appreciated. Also, if you think I'm making the code too complicated, please let me know

Here is the full module:

Option Explicit

Sub ExecSQL()

    Dim ActSh As Worksheet
    Set ActSh = ActiveSheet

    'Connect to the database
    Dim Conn As ADODB.Connection
    Set Conn = NewDBConnection()

    'Get the SQL statements and Worksheets from this sheet
    Dim Stmt As Scripting.Dictionary
    Set Stmt = GetStatements()
    If Not CheckStatements(Stmt) Then Exit Sub

    'Execute the SQL commands and paste the results
    Dim Sh As Variant
    For Each Sh In Stmt.Keys()
        If Not ExecSQLStmt(Conn, Sh, Stmt(Sh)) Then Exit Sub
    Next

    'Clean up
    Conn.Close
    Set Conn = Nothing
    ActSh.Activate
    MsgBox "SQL statement execution completed", vbInformation + vbOKOnly, "Completed"
End Sub

Private Function NewDBConnection() As ADODB.Connection

    Dim ConStr As String

    ConStr = "" _
        & "Provider=SQLOLEDB.1;" _
        & "Password={redacted};" _
        & "Persist Security Info=True;" _
        & "User ID={redacted};" _
        & "Initial Catalog={redacted};" _
        & "Data Source={redacted};" _
        & "Use Procedure for Prepare=1;" _
        & "Auto Translate=True;" _
        & "Packet Size=4096;" _
        & "Workstation ID=W530;" _
        & "Use Encryption for Data=False;" _
        & "Tag with column collation when possible=False"

    Dim Conn As ADODB.Connection
    Set Conn = New ADODB.Connection

    Conn.Open ConStr

    Set NewDBConnection = Conn
End Function

Private Function GetStatements() As Scripting.Dictionary

    Dim Rng As Range
    Set Rng = ActiveSheet.UsedRange

    Dim Row As Long
    Dim RowHdr As Long
    Dim RowCount As Long
    RowHdr = 0
    RowCount = Rng.Rows.Count

    Dim Col As Long
    Dim ColSh As Long
    Dim ColSQL As Long
    Dim ColCount As Long
    ColSh = 0
    ColSQL = 0
    ColCount = Rng.Columns.Count

    'Get the header row and applicable columns
    Dim ValHdr As String
    For Row = 1 To RowCount
        For Col = 1 To ColCount
            ValHdr = UCase(Trim(GetStrValue(Rng.Cells(Row, Col))))

            If ValHdr = "!SHEET" Then
                RowHdr = Row
                ColSh = Col
            ElseIf ValHdr = "!SQL" Then
                RowHdr = Row
                ColSQL = Col
            End If
        Next
        If RowHdr > 0 Then Exit For
    Next

    'Scan the rows for any applicable entries
    Dim Stmt As Scripting.Dictionary
    Set Stmt = New Scripting.Dictionary

    Dim ValSh As String
    Dim ValSQL As String
    If ColSh > 0 And ColSQL > 0 Then
        For Row = RowHdr + 1 To RowCount
            ValSh = Trim(GetStrValue(Rng.Cells(Row, ColSh)))
            ValSQL = Trim(GetStrValue(Rng.Cells(Row, ColSQL)))

            If ValSh <> "" And ValSQL <> "" Then
                Stmt(ValSh) = ValSQL
            End If
        Next
    End If

    Set GetStatements = Stmt
End Function

Private Function CheckStatements(Stmt As Scripting.Dictionary) As Boolean

    Dim ErrStr As String
    ErrStr = ""

    If Stmt.Count = 0 Then
        ErrStr = "Could not find any SQL statements on the current sheet." _
            & vbCrLf _
            & "Did you remember to add ""!Sheet"" and ""!SQL"" header tags?"
    End If

    If ErrStr = "" Then
        CheckStatements = True
    Else
        MsgBox ErrStr, vbCritical + vbOKOnly, "Error"
        CheckStatements = False
    End If
End Function

Private Function GetStrValue(Rng As Range) As String
    'Get the value of a cell, but do not throw and error if the cell
    'contains and error. Intead, just return an empty string

    Dim Val As String
    Val = ""

    On Error Resume Next
    Val = Rng.Value
    On Error GoTo 0

    GetStrValue = Val
End Function

Private Function ExecSQLStmt(Conn As ADODB.Connection, ByVal ShName As String, SQLStmt As String) As Boolean
    'Execute the SQL statement and paste the result into the corresponding sheet

    Dim Sh As Worksheet

    'Delete the sheet if it already exists
    On Error Resume Next
    Set Sh = ActiveWorkbook.Worksheets(ShName)
    On Error GoTo 0
    If Not Sh Is Nothing Then
        Application.DisplayAlerts = False
        Sh.Delete
        Application.DisplayAlerts = True
    End If

    'Create the sheet
    With ActiveWorkbook
        Set Sh = .Sheets.Add(After:=.Sheets(.Sheets.Count))
    End With
    Sh.Name = ShName

    'Execute the SQL statement
    Dim Rs As ADODB.Recordset
    On Error Resume Next
    Set Rs = Conn.Execute(SQLStmt)
    If Rs Is Nothing Then
        Dim ErrStr As String
        ErrStr = "There was an error executing the SQL statement" & vbCrLf _
            & SQLStmt & vbCrLf _
            & vbCrLf _
            & "Error: " & Err.Description
        MsgBox ErrStr, vbCritical + vbOKOnly, "Error"
        ExecSQLStmt = False
        Exit Function
    End If
    On Error GoTo 0

    'Paste the result into the sheet
    Dim Col As Long
    For Col = 1 To Rs.Fields.Count
        Sh.Cells(1, Col).Value = Rs.Fields(Col - 1).Name
    Next
    Sh.Cells(1, 1).EntireRow.Font.Bold = True
    Sh.Range("A2").CopyFromRecordset Rs

    ExecSQLStmt = True
End Function
\$\endgroup\$
2
\$\begingroup\$

Everytime you pass something without ByVal you are passing it ByRef, which is general, isn't necessary.

I also see you passing a Scripting.Dictionary around, ByRef. I'd pass this as an object, but I also always use late-binding. I think this indicates you should create a custom Class to replace the dictionary.

Private Function GetStatements() As Scripting.Dictionary

It's strange to pass nothing to a function. It's a function because you're creating and returning the object. Even if you just pass UsedRange it would make more sense, though I see how you ended up there.

Private Function NewDBConnection() As ADODB.Connection

It looks to me like you could just have your connection string as a constant and you could open the connection without this entire function. However, I don't think that would be better than this - if just for readability and clarity.

Variables

You have a lot of pretty generically named variables e.g. Conn, Rng, etc. You're also using ProperCase for variables, when they should use camelCase. So if we were to evaluate your variables

Dim ActSh As Worksheet 

This is ActiveSheet, a built-in variable, I don't think it's needed. If it is, then it needs to tell me what's on the sheet.

Dim Conn As ADODB.Connection

I mean, this is okay..

Dim Stmt As Scripting.Dictionary

Why would a statement be a dictionary? You've lost me with this name

Dim Sh As Variant

This is a key right?

Dim ConStr As String

connectionString is better

Dim Rng As Range

Tell me what range this is - I know it's a range when you declare it as a range

Dim Row As Long

I avoid using things like Row or Column because they are key words in the VBE. I usually go with targetRow or currentRow

Dim RowHdr As Long

Characters are free! rowHeader would be better, but it sounds like a string, not a long. headerRow sounds better.

Dim RowCount As Long rowCount
Dim Col As Long targetColumn
Dim ColSh As Long
Dim ColSQL As Long

I'm not sure what these are - use the name to tell me what I want to know!

Dim ColCount As Long columnCount
Dim ValHdr As String
Dim ValSh As String
Dim ValSQL As String

Same, tell me what these are!

Dim Val As String

A value as a string. You know what I'm going to say, right? Dim Sh As Worksheet ? Dim Rs As ADODB.Recordset I see this a lot, so I guess it's okay

Function Names

Private Function ExecSQLStmt(Conn As ADODB.Connection, ByVal ShName As String, SQLStmt As String) As Boolean

This is an execute function that returns a boolean - why? Either it's executing and returns nothing, or it's executing and returning a result, right?

Private Function CheckStatements(Stmt As Scripting.Dictionary) As Boolean

If this is a boolean function, try to name it as such e.g. IsValidStatement or some such.

GetStatements

With the variable naming as it is, it's very difficult to tell exactly what's happening upon a cursory glance.

  • With usedrange
  • for each row
  • for each column
  • check if cell = !SHEET or !SQL
  • Set sheet or statement
  • If we're not on row 1, exit for

Seems your first check would just be to loop through only the rows you want, instead of looping through everything just to exit on row 2? I mean it's difficult to tell what exactly the goal is, but if you just want the header row to be row 1, why not just get row 1? You're setting these values within the If but then looping and replacing them without doing anything with them.

  • If there's both a header and a statement then
  • Get range values and place them into the dictionary, if they aren't empty

Seems pretty straight forward. In fact, you could just take everything into an array first and you wouldn't need the dictionary

Private Function GetMyValues(ByVal target As Range) As Variant
    GetMyValues = target
End Function

Bam! No need to look at the sheet again. Now you can loop through the array and store your results in another array or just expand your current array. Then when you are done, print them back out as an array.

Error handling?

Private Function GetStrValue(Rng As Range) As String
    'Get the value of a cell, but do not throw and error if the cell
    'contains and error. Intead, just return an empty string

    Dim Val As String
    Val = ""

    On Error Resume Next
    Val = Rng.value
    On Error GoTo 0

    GetStrValue = Val
End Function

When you declare a string it is declared as vbNullString which is a constant for your "". So, first, no need to set it to anything if it's going to be nothing. Second, always use vbNullString when you can instead of ""

Third, I don't see a way for this to throw an error, unless you don't pass it a valid range. This function, as far as I can see, not needed at all.

\$\endgroup\$
  • \$\begingroup\$ Thanks for this. I appreciate the help and will try to adapt going forward. Just to explain some of the reasoning: (1) The loop to get headers goes through the whole sheet, but escapes when found because it makes it possible to move things around without breaking the code. The header row could be 1, 2 or 10. The same for columns. (2) I'm returning boolean on ExecSQLStmt as a way to pass errors. I think this could be done better, but I'm not yet sure how. (3) The reason for error handling is if you do {StringVariable} = Rng.Value on a cell containing an error it will throw an error in VBA \$\endgroup\$ – neelsg Aug 17 '18 at 7:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.