Thursday, 8 February 2018

VBA - Multi-threaded Asynchronous Google Sheet Downloader

Summary: Google Sheets is web/cloud based, this means web calls to get data. Here we use multi-threading and asynchronous callbacks to make the a download lightning fast and fully responsive.

Just to clarify, VBA is inherently single threaded but it can call components that are in turn multi-threaded (or appear so) to make the VBA also appear multi-threaded. In this instance the HTTP Request components are (or appear to be) multi-threaded. So we can fire off a multitude of HTTP requests. But we need a way to handle the results as they come in, thankfully we have the function delegates from the prior article.

Also in this program we use the ScriptControl to host some JavasScript function because I believe that is better suited to parsing JSON which essentially is what this task is. But we go further and actually write the values onto a worksheet with Javascript, this illustrates the inter-operability of the ScriptControl.

The AsynchronousGoogleSheet class module


Option Explicit

'* Tools->References
'*     WinHttp    Microsoft WinHTTP Services, version 5.1     C:\WINDOWS\system32\winhttpcom.dll


Private WithEvents moXHR As WinHttp.WinHttpRequest

Private mfnFunctionDelegateOnError As FunctionDelegate
Private mfnFunctionDelegateOnResponseFinished As FunctionDelegate
Private msSheetTitle As String

'---------------------------------------------------------------------------------------
' Procedure : RunAsynchronous
' DateTime  : 06/02/2018 16:20
' Author    : Simon
' Purpose   :
'---------------------------------------------------------------------------------------
' Arguments :
'    sHttpMethod                  : one of {GET, PUT, POST, DELETE}
'    sURL                         : the web address
'    fnOnErrorDelegate            : the delegate of the function we want to call on an error
'    fnOnResponseFinishedDelegate : the delegate of the function we want to call on completion
'
Public Sub RunAsynchronous(ByVal sSheetTitle As String, ByVal sHttpMethod As String, ByVal sURL As String, _
        ByVal fnOnErrorDelegate As FunctionDelegate, ByVal fnOnResponseFinishedDelegate As FunctionDelegate)

    Set mfnFunctionDelegateOnError = fnOnErrorDelegate
    Set mfnFunctionDelegateOnResponseFinished = fnOnResponseFinishedDelegate

    msSheetTitle = sSheetTitle


    Set moXHR = New WinHttp.WinHttpRequest
    moXHR.Open sHttpMethod, sURL, True
    moXHR.Send
    

End Sub



'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnError
' DateTime  : 06/02/2018 16:16
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383929(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnError(ByVal ErrorNumber As Long, ByVal ErrorDescription As String)
    'Debug.Print "moXHR_OnError"
    If Not mfnFunctionDelegateOnError Is Nothing Then
        mfnFunctionDelegateOnError.Run ErrorNumber, ErrorDescription
    End If
    
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseDataAvailable
' DateTime  : 06/02/2018 16:16
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383941(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseDataAvailable(Data() As Byte)
    'Debug.Print "moXHR_OnResponseDataAvailable"
    '* not interested
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseFinished
' DateTime  : 06/02/2018 16:17
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383946(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseFinished()
    'Debug.Print "moXHR_OnResponseFinished"
    If Not mfnFunctionDelegateOnResponseFinished Is Nothing Then
        If Len(msSheetTitle) = 0 Then
            '* it's the master detail page
            mfnFunctionDelegateOnResponseFinished.Run moXHR.ResponseText
        Else
            '* it's an individual sheet
            mfnFunctionDelegateOnResponseFinished.Run msSheetTitle, moXHR.ResponseText
        End If
    End If
    
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseStart
' DateTime  : 06/02/2018 16:17
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383954(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseStart(ByVal Status As Long, ByVal ContentType As String)
    'Debug.Print "moXHR_OnResponseStart"
    '* not interested
End Sub

The modGoogleSheetsImporter standard module


Option Explicit
Option Private Module

'* Tools->References
'*   WinHttp               Microsoft WinHTTP Services, version 5.1     C:\WINDOWS\system32\winhttpcom.dll
'*   MSScriptControl       Microsoft Script Control 1.0                C:\Windows\SysWOW64\msscript.ocx
'*   Scripting             Microsoft Scripting Runtime                 C:\Windows\SysWOW64\scrrun.dll
'*   MSXML2                Microsoft XML, v6.0                         C:\Windows\SysWOW64\msxml6.dll



'---------------------------------------------------------------------------------------
' Module    : modGoogleSheetsImporter
' DateTime  : 08/02/2018 22:15
' Author    : Simon
' Purpose   : Asynchronously downloads individual Google Sheets
'---------------------------------------------------------------------------------------


Private moAsyncXHRMaster As AsynchronousGoogleSheet
Private marrAsyncXHR() As AsynchronousGoogleSheet

Private msWorkbookID As String
Private msAPIKey As String

'---------------------------------------------------------------------------------------
' Procedure : SC
' DateTime  : 08/02/2018 22:06
' Author    : Simon
' Purpose   : Sets up, caches and returns an instance of ScriptControl loaded with our
'             Javascript functions that include
'               deleteValueByKey()  which removes a property from an object
'               setValueByKey() adds or replaces a property on an object
'               enumKeysToMsDict() enumerates an object's property names to a Dictionary
'               JSON_parse() takes a string and parses to a JScriptTypeInfo object
'               JSON_stringify() takes a JScriptTypeInfo object and converts to a string
'---------------------------------------------------------------------------------------
' Arguments :
'    [out,retval]   : A ScriptControl instance loaded with our Javascript functions
'
Public Function SC() As ScriptControl
    Static soSC As ScriptControl
    If soSC Is Nothing Then


        Set soSC = New ScriptControl
        soSC.Language = "JScript"

        soSC.AddCode "function deleteValueByKey(obj,keyName) { delete obj[keyName]; } "
        soSC.AddCode "function setValueByKey(obj,keyName, newValue) { obj[keyName]=newValue; } "
        soSC.AddCode "function enumKeysToMsDict(jsonObj,msDict) { for (var i in jsonObj) { msDict.Add(i,0); }  } "
        soSC.AddCode GetJavaScriptLibrary("https://raw.githubusercontent.com/douglascrockford/JSON-js/master/json2.js")
        soSC.AddCode "function JSON_stringify(value, replacer,spacer) { return JSON.stringify(value, replacer,spacer); } "
        soSC.AddCode "function JSON_parse(sJson) { return JSON.parse(sJson); } "

        Dim sFnExtractTitleToMsDict As String
        sFnExtractTitleToMsDict = "function extractTitleToMsDict(text,dict) {" & _
                                "    try {" & _
                                "        var doc = JSON.parse(text);" & _
                                "        for (var i = 0; i < doc.sheets.length; i++) { " & _
                                "            var sheetLoop = doc.sheets[i]; " & _
                                "            var title = sheetLoop.properties.title; " & _
                                "            dict.Add(title, i); " & _
                                "        } " & _
                                "        return dict.Count; " & _
                                "    } " & _
                                "    catch (ex) { " & _
                                "        return ('#error in extractTitleToMsDict!'); " & _
                                "    } " & _
                                "}"
        soSC.AddCode sFnExtractTitleToMsDict

        Dim sFnWriteGoogleSheetToExcelWorksheet As String
        sFnWriteGoogleSheetToExcelWorksheet = "function writeGoogleSheetToExcelWorksheet(text,ws,app) {" & _
                                "    try {" & _
                                "        var doc = JSON.parse(text);" & _
                                "        var bByRows=(doc.majorDimension === 'ROWS'); " & _
                                "        var rngLoop;" & _
                                "        var docValues = doc['values'];" & _
                                "        for (var i = 0; i < docValues.length; i++) { " & _
                                "            var major = doc.values[i] ;" & _
                                "            for (var j = 0; j < major.length; j++) { " & _
                                "                if (bByRows) {" & _
                                "                    rngLoop = ws.cells(i+1,j+1); " & _
                                "                } else {" & _
                                "                    rngLoop = ws.cells(j+1,i+1); " & _
                                "                } " & _
                                "                rngLoop.value=major[j]; " & _
                                "            } " & _
                                "        } " & _
                                "        return true; " & _
                                "    } " & _
                                "    catch (ex) { return ('#error in writeGoogleSheetToExcelWorksheet!'); } " & _
                                "}"
'                                "                app.Run('log','i:' + i + '  j:'+j + '  ' + rngLoop.Address + '  ' + major[j]); " & _

        soSC.AddCode sFnWriteGoogleSheetToExcelWorksheet



    End If
    Set SC = soSC
End Function

'---------------------------------------------------------------------------------------
' Procedure : GetJavaScriptLibrary
' DateTime  : 08/02/2018 22:05
' Author    : Simon
' Purpose   : Download core Javascript libraries such as Douglas Crockford's JSON parser
'             we use MSXML2.XMLHTTP60 here because it caches and the content is static
'---------------------------------------------------------------------------------------
' Arguments :
'  [in]sURL      : The URL of the Javascript library
'  [out,retval]  : The javascript source
'
Private Function GetJavaScriptLibrary(ByVal sURL As String) As String

    Dim xHTTPRequest As MSXML2.XMLHTTP60
    Set xHTTPRequest = New MSXML2.XMLHTTP60
    xHTTPRequest.Open "GET", sURL, False
    xHTTPRequest.Send
    GetJavaScriptLibrary = xHTTPRequest.ResponseText

End Function

'----------------------------------------------------------------------------------------------
' Procedure : AddOrClearSheets
' DateTime  : 08/02/2018 22:03
' Author    : Simon
' Purpose   : We can add the sheets in advance, synchronously
'----------------------------------------------------------------------------------------------
' Arguments :
'  [in]dicTitles    : a dictionary contained the titles extracted from the master detail page
'  [in]lSheetCount  : used to test on a smaller subset
'
Private Sub AddOrClearSheets(ByVal dicTitles As Scripting.Dictionary, ByVal lSheetCount As Long)

    Dim dicCurrentSheets As Scripting.Dictionary
    Set dicCurrentSheets = GetCurrentSheets

    '* add the sheets synchronously
    Dim lLoop As Long
    For lLoop = 0 To lSheetCount - 1


        Dim sTitleLoop As String
        sTitleLoop = dicTitles.Keys()(lLoop)

        If Not dicCurrentSheets.Exists(sTitleLoop) Then
            Dim wsAdded As Excel.Worksheet
            Set wsAdded = ThisWorkbook.Worksheets.Add
            wsAdded.Name = sTitleLoop
            dicCurrentSheets.Add sTitleLoop, 0
        Else
            Dim wsLoop As Excel.Worksheet
            Set wsLoop = ThisWorkbook.Worksheets.Item(sTitleLoop)
            wsLoop.Cells.Clear
            
        End If

    Next

End Sub

'---------------------------------------------------------------------------------------
' Procedure : GetIndividualSheetTitles
' DateTime  : 08/02/2018 21:55
' Author    : Simon
' Purpose   : Calls into the Javascript function extractTitleToMsDict because Javascript
'             is better suited to working with JSON
'---------------------------------------------------------------------------------------
' Arguments :
'   [in]sMasterPageJSON : This is JSON of the master page
'   [out,retval]        : a dictionary containing the sheet titles in the Google Sheets workbook
'
Private Function GetIndividualSheetTitles(ByVal sMasterPageJSON As String) As Scripting.Dictionary
    Dim dicTitles As Scripting.Dictionary
    Set dicTitles = New Scripting.Dictionary
    

    Dim vCount As Variant
    vCount = SC.Run("extractTitleToMsDict", sMasterPageJSON, dicTitles)

    If Not IsNumeric(vCount) Then Err.Raise vbError, , "#Error whilst running extractTitleToMsDict!"

    Set GetIndividualSheetTitles = dicTitles


End Function

'---------------------------------------------------------------------------------------
' Procedure : GetCurrentSheets
' DateTime  : 08/02/2018 21:54
' Author    : Simon
' Purpose   : Instead of calling Worksheet.Item on a sheet that doesn't exist, I'd prefer
'             to get a dictionary of the sheets and call Dictionary.Exists()
'---------------------------------------------------------------------------------------
' Arguments :
'   [out,retval]    : a dictionary containing the sheet names in this workbook
'
Private Function GetCurrentSheets() As Scripting.Dictionary

    Dim dicCurrentSheets As Scripting.Dictionary
    Set dicCurrentSheets = New Scripting.Dictionary


    Dim wsLoop As Excel.Worksheet
    For Each wsLoop In ThisWorkbook.Worksheets
        dicCurrentSheets.Add wsLoop.Name, 0
    Next wsLoop

    Set GetCurrentSheets = dicCurrentSheets

End Function

'---------------------------------------------------------------------------------------
' Procedure : TestDownloadGoogleSheetToExcel
' DateTime  : 08/02/2018 21:52
' Author    : Simon
' Purpose   : Test stub
'---------------------------------------------------------------------------------------
'
Sub TestDownloadGoogleSheetToExcel()
    
    Call DownloadGoogleSheetToExcel("1nDSd38lIQj_aTWDRPJ-aPybR0NwFdQ8GLSDJM0-QaR4", gscKey)
End Sub

'---------------------------------------------------------------------------------------
' Procedure : DownloadGoogleSheetToExcel
' DateTime  : 08/02/2018 21:45
' Author    : Simon
' Purpose   : This sets up the asynchronous master detail page web call
'
' Notes     : You can find out the workbookid by looking at the url in the browser when editing
' the sheet in Google Sheets so my url is
'
'  https://docs.google.com/spreadsheets/d/1nDSd38lIQj_aTWDRPJ-aPybR0NwFdQ8GLSDJM0-QaR4/edit#gid=1544561606
'
' So the format is
'
'  https://docs.google.com/spreadsheets/d/[sWorkbookID]/edit#gid=1544561606
'
' As for sAPIKey acquire your own at Google Developer Console
'
'  https://console.developers.google.com/iam-admin/quotas
'
'---------------------------------------------------------------------------------------
' Arguments :
'    [in]sWorkbookID    : the Google Sheet ID for the whole workbook
'    [in]sAPIKey        : you need to get your own APIKey
'
Sub DownloadGoogleSheetToExcel(ByVal sWorkbookID As String, ByVal sAPIKey As String)
    
    msWorkbookID = sWorkbookID
    msAPIKey = sAPIKey


    Dim sMasterTemplate As String
    sMasterTemplate = "https://sheets.googleapis.com/v4/spreadsheets/[workbook_id]?key=[apikey]"

    Dim sMasterPageURL As String
    sMasterPageURL = VBA.Replace(VBA.Replace(sMasterTemplate, "[workbook_id]", sWorkbookID), "[apikey]", sAPIKey)
    
    Set moAsyncXHRMaster = New AsynchronousGoogleSheet
    
    Call moAsyncXHRMaster.RunAsynchronous("", "GET", sMasterPageURL, _
                    FnFactory.FnAppRun("Async_OnError"), _
                    FnFactory.FnAppRun("ContinueAndDownloadIndividualSheets"))
    
    
End Sub
    
'---------------------------------------------------------------------------------------
' Procedure : ContinueAndDownloadIndividualSheets
' DateTime  : 08/02/2018 22:23
' Author    : Simon
' Purpose   : Called by a function delegate when the asynchronous Master Detail Page web
'             call has completed
'---------------------------------------------------------------------------------------
' Arguments :
'    [in]sMasterPageJSON    : the JSON of the master detail page
'
Sub ContinueAndDownloadIndividualSheets(ByVal sMasterPageJSON As String)
    
    Dim dicTitles As Scripting.Dictionary
    Set dicTitles = GetIndividualSheetTitles(sMasterPageJSON)

    Dim sDetailTemplate As String
    sDetailTemplate = "https://sheets.googleapis.com/v4/spreadsheets/[workbook_id]/values/[sheet_title]?key=[apikey]"


    If dicTitles.Count > 0 Then

        Dim lSheetCount As Long
        lSheetCount = 3 'dicTitles.Count '* separate variable so I can test on small number

        ReDim marrAsyncXHR(0 To lSheetCount - 1) As AsynchronousGoogleSheet
        

        AddOrClearSheets dicTitles, lSheetCount

        Dim lLoop As Long
        For lLoop = 0 To lSheetCount - 1

            Dim sTitleLoop As String
            sTitleLoop = dicTitles.Keys()(lLoop)

            Dim sDetailPageURL As String
            sDetailPageURL = VBA.Replace(VBA.Replace(VBA.Replace(sDetailTemplate, "[workbook_id]", msWorkbookID), "[apikey]", msAPIKey), "[sheet_title]", sTitleLoop)

            Set marrAsyncXHR(lLoop) = New AsynchronousGoogleSheet
            Call marrAsyncXHR(lLoop).RunAsynchronous(sTitleLoop, "GET", sDetailPageURL, _
                        FnFactory.FnAppRun("Async_OnError"), _
                        FnFactory.FnAppRun("SheetAvailable"))


        Next
        
    End If
End Sub

'---------------------------------------------------------------------------------------
' Procedure : Async_OnError
' DateTime  : 08/02/2018 21:44
' Author    : Simon
' Purpose   : reports error (not seen this executed once yet)
'---------------------------------------------------------------------------------------
' Arguments :
'    [in]ErrorNumber        : passed on from WinHttp.WinHttpRequest OnError event
'    [in]ErrorDescription   : passed on from WinHttp.WinHttpRequest OnError event
'
Public Sub Async_OnError(ByVal ErrorNumber As Long, ByVal ErrorDescription As String)
    Debug.Print
    Debug.Print ErrorNumber, ErrorDescription
    Stop
    
End Sub


'---------------------------------------------------------------------------------------
' Procedure : SheetAvailable
' DateTime  : 08/02/2018 21:42
' Author    : Simon
' Purpose   :
'---------------------------------------------------------------------------------------
' Arguments :
'    [in]sSheetTitle    : the name of sheet pass to Worksheets.Item()
'    [in]sSheetJSON     : the JSON string representation of a Google Sheet worksheet
'
Public Sub SheetAvailable(ByVal sSheetTitle As String, ByVal sSheetJSON As String)

    Dim ws As Excel.Worksheet
    Set ws = ThisWorkbook.Worksheets.Item(sSheetTitle) 'should work because we added all of them earlier
    ws.Cells.Clear
    ws.Activate

    '* call into our Javascript writer
    Dim vWrite As Variant
    vWrite = SC.Run("writeGoogleSheetToExcelWorksheet", sSheetJSON, ws, Application)

End Sub

'---------------------------------------------------------------------------------------
' Procedure : Log
' DateTime  : 08/02/2018 21:52
' Author    : Simon
' Purpose   : This is callable from JavaScript using Application.Run('Log','hello world');
'---------------------------------------------------------------------------------------
' Arguments :
'    [in]vMsg   : the message to log
'
Function Log(vMsg)
    Debug.Print vMsg
End Function

Tuesday, 6 February 2018

VBA - Function Delegates - borrow a feature from C#

Summary: Other languages treat functions more as first class citizens in that one can pass a function pointer (C++) around just like a variable. In C# there are function delegates which are like an object orientated version of function pointers. We can do something similar in VBA but there is an unfortunately duality that needs to be unified or abstracted.

So other languages like C++ have function pointers which can be used for callbacks (for say event handling) or the visitor design pattern (think of passing a comparison function into a C++ sort routine). Indeed, in C++ a pointer to a COM interface is in fact a pointer a whole table of function pointers. C# has function delegates which are like object orientated versions of C++ function pointers. In VBA, we can do something like function delegates of C# but not the pointers of C++ because VBA does not have pointers. Both C++ and C# will score higher than anything I can write in VBA because they have argument type checking. Moreover, VBA will never have inline function definitions like they have in Javascript or (as far as I can see) lambda expressions.

The real problem in VBA is the duality between calling a function in a standard module using Application.Run() and calling a function of a class instance using VBA.CallByname(). We can solve this by adding a class to route execution to the correct branch of code.

Without pulling a trick the extra layer will mean that the code has to read with the Run method being visible, this is not as nice as C++ or C#.

fnFoo.Run("bar","barry")

But we can pull a trick to hide the Run() by adding an attribute to the Run making its DispID = 0 added layer. This needs to be done on a code module that has been exported and edited in a text editor (Notepad). VBA has a different name for DispID, they call VB_UserMemId = 0 so the attribute line to add in an exported code module is as following.

Attribute Item.VB_UserMemId = 0

Once this atribute is added, the text file saved and re-imported into VBA project we can then call the same line of code without Run thus ...

fnFoo("bar","barry")

So the code is given below. There are two core classes FnFactory and FunctionDelegate and three test modules (2 classes+1 standard module) FunctionDelegateTestClass, tstFunctionDelegate and AsynchronousWebCall. So import these into a fresh workbook VBA project. You'll need a reference to Microsoft WinHTTP Services, version 5.1 (C:\WINDOWS\system32\winhttpcom.dll) for the AsynchronousWebCall class to compile. Then run the tests.

The AsynchronousWebCall class and its calling code given here show the compact syntax I was aiming for in terms of specifying which functions to call for each event of OnError and OnResponseFinished on an XHR. This is as tight and as close as I can get to the Javascript syntax for ajax calls but is quite satisfactory (to me at least).

Private Sub Test_AsynchronousWebCall_CompactSyntax()
    '* run this test to see function delegates in action, this has compact syntax
    
    Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
    Set oAsyncXHR = New AsynchronousWebCall
    
    Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", _
                FnFactory.FnAppRun("TestOnError"), _
                FnFactory.FnAppRun("TestOnResponseFinished"))

End Sub

The Code Listings

The FnFactory class

This Contains factory methods to frees us from the syntactical constraints around the New keyword.

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "FnFactory"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
'---------------------------------------------------------------------------------------
' Module    : FnFactory
' DateTime  : 06/02/2018 15:57
' Author    : Simon
' Purpose   : Contains factory methods to frees us from the syntactical constraints
'             around the New keyword
' Deployment: Open a text editor and ensure the line reads "Attribute VB_PredeclaredId = True"
'             so that one will not need to New this module!
'---------------------------------------------------------------------------------------



'---------------------------------------------------------------------------------------
' Procedure : FnAppRun
' DateTime  : 05/02/2018 14:05
' Author    : Simon
' Purpose   : A factory method to create a FunctionDelegate containing enough info
'             to pass to Application.Run(.. , .. , .. , ...)
'             Using a factory method frees us from the syntactical constraints
'             around the New keyword
'---------------------------------------------------------------------------------------
' Arguments :
'    [in] sMacro                 : the name of macro passed to Application.Run
'    [in] bReturnTypeIsObject    : whether or not we need to say 'Set foo=Application.Run(...' for returning an object
'    [out,retval]                : returns a create instance of FunctionDelegate containing the passed details
'
Public Function FnAppRun(ByVal sMacro As String, Optional ByVal bReturnTypeIsObject As Boolean) As FunctionDelegate
    
    
    Dim oNewFD As FunctionDelegate
    Set oNewFD = New FunctionDelegate
    
    oNewFD.IsAppRun = True
    oNewFD.AppRunMacro = sMacro
    oNewFD.ReturnTypeIsObject = bReturnTypeIsObject
    'oNewFD.mvArgs = vargs
    
    Set FnAppRun = oNewFD

End Function


'---------------------------------------------------------------------------------------
' Procedure : FnCallByName
' DateTime  : 06/02/2018 15:50
' Author    : Simon
' Purpose   : A factory method to create a FunctionDelegate containing enough info
'             to pass to VBA.CallByName(.. , .. , .. , ...)
'             Using a factory method frees us from the syntactical constraints
'             around the New keyword
'---------------------------------------------------------------------------------------
' Arguments :
'    [in] oCallByNameTarget      : the object (class instance) on whom we want to call the method
'    [in] sMacro                 : the name of method we want to call
'    [in] eCallByNameType        : necessary to specify one of {VbSet, VbMethod, VbLet, VbGet}
'    [in] bReturnTypeIsObject    : whether or not we need to say 'Set foo=VBA.CallByName(...' for returning an object
'    [out,retval]                : returns a create instance of FunctionDelegate containing the passed details
'
Public Function FnCallByName(ByVal oCallByNameTarget As Object, ByVal sMacro As String, _
                        ByVal eCallByNameType As VbCallType, Optional bReturnTypeIsObject As Boolean) As FunctionDelegate
    
    
    Dim oNewFD As FunctionDelegate
    Set oNewFD = New FunctionDelegate
    
    oNewFD.IsAppRun = False
    Set oNewFD.CallByNameTarget = oCallByNameTarget
    oNewFD.ReturnTypeIsObject = bReturnTypeIsObject
    oNewFD.CallByNameType = eCallByNameType
    
    oNewFD.AppRunMacro = sMacro
    'oNewFD.mvArgs = vargs
    
    Set FnCallByName = oNewFD

End Function

The FunctionDelegate class

This class contains enough information to call a function either using (a) Application.Run() if function lives in a standard module or (b) VBA.CallByName() if function lives in a class instance (object). It is recommended (but not necessary) one uses the FnFactory class for more compact code for instantiation of this class.

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "FunctionDelegate"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
'---------------------------------------------------------------------------------------
' Module    : FunctionDelegate
' DateTime  : 06/02/2018 16:04
' Author    : Simon
' Purpose   : Contains enough information to call a function either using
'             (a) Application.Run() if function lives in a standard module or
'             (b) VBA.CallByName() if function lives in a class instance (object)
'
'             Recommended (but not necessary) one uses the FnFactory class for more compact
'             code for instantiation of this class.
'---------------------------------------------------------------------------------------


Private msAppRunMacro As String
Private mobjCallByNameTarget As Object
Private meCallByNameType As VbCallType
Private mbIsAppRun As Boolean
Private mbReturnTypeIsObject As Boolean

Public Property Get ReturnTypeIsObject() As Boolean
    ReturnTypeIsObject = mbReturnTypeIsObject
End Property
Public Property Let ReturnTypeIsObject(ByVal bReturnTypeIsObject As Boolean)
    mbReturnTypeIsObject = bReturnTypeIsObject
End Property

Public Property Get IsAppRun() As Boolean
    IsAppRun = mbIsAppRun
End Property
Public Property Let IsAppRun(ByVal bIsAppRun As Boolean)
    mbIsAppRun = bIsAppRun
End Property

Public Property Get CallByNameType() As VbCallType
    CallByNameType = meCallByNameType
End Property

Public Property Let CallByNameType(ByVal eCallByNameType As VbCallType)
    meCallByNameType = eCallByNameType
End Property

Public Property Get CallByNameTarget() As Object
    Set CallByNameTarget = mobjCallByNameTarget
End Property
Public Property Set CallByNameTarget(ByVal objCallByNameTarget As Object)
    Set mobjCallByNameTarget = objCallByNameTarget
End Property

Public Property Get AppRunMacro() As String
    AppRunMacro = msAppRunMacro
End Property
Public Property Let AppRunMacro(ByVal sAppRunMacro As String)
    msAppRunMacro = sAppRunMacro
End Property


'---------------------------------------------------------------------------------------
' Procedure : Run
' DateTime  : 06/02/2018 16:01
' Author    : Simon
' Purpose   : This runs/executes/calls the function.  Deployed correctly one can omit
'             .Run in the calling line see unit tests for example
'
' Deployment: *** need to ensure that this has the line "Attribute Item.VB_UserMemId = 0"
'                 to make it default ***
'---------------------------------------------------------------------------------------
' Arguments :
'    vargs()    : a variable list of arguments which we'll pass on to Application.Run()
'                 or VBA.CallByName()
'
Public Function Run(ParamArray vargs() As Variant)
Attribute Run.VB_UserMemId = 0
    Dim lArgCount As Long
    lArgCount = UBound(vargs) - LBound(vargs) + 1
    
    If mbIsAppRun Then
    
        If lArgCount = 0 Then
            If mbReturnTypeIsObject Then
                Set Run = Application.Run(msAppRunMacro)
            Else
                Run = Application.Run(msAppRunMacro)
            End If
        ElseIf lArgCount = 1 Then
            If mbReturnTypeIsObject Then
                Set Run = Application.Run(msAppRunMacro, vargs(0))
            Else
                Run = Application.Run(msAppRunMacro, vargs(0))
            End If
        ElseIf lArgCount = 2 Then
            If mbReturnTypeIsObject Then
                Set Run = Application.Run(msAppRunMacro, vargs(0), vargs(1))
            Else
                Run = Application.Run(msAppRunMacro, vargs(0), vargs(1))
            End If
        Else
            'requires more lines to handle multiple arguments,
            'a bit ugly so will do later
        End If
    Else
        If Not mobjCallByNameTarget Is Nothing Then
                
            If lArgCount = 0 Then
                
                If mbReturnTypeIsObject Then
                    Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType)
                Else
                    Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType)
                End If
            
            ElseIf lArgCount = 1 Then
                
                If mbReturnTypeIsObject Then
                    Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0))
                Else
                    Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0))
                End If
            
            ElseIf lArgCount = 2 Then
                
                If mbReturnTypeIsObject Then
                    Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0), vargs(1))
                Else
                    Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0), vargs(1))
                End If
            
            Else
                'requires more lines to handle multiple arguments,
                'a bit ugly so will do later
            End If
                
        End If
                
    End If
End Function

The FunctionDelegateTestClass class

This is test fodder for the function delegate and is not core to the application; we need to test the CallByName use case so we need a class and some methods to call.

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "FunctionDelegateTestClass"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit

'---------------------------------------------------------------------------------------
' Module    : FunctionDelegateTestClass
' DateTime  : 06/02/2018 16:38
' Author    : Simon
' Purpose   : To house some procedures to be called using instances of FunctionDelegate
'             (not really to be called directly)
'---------------------------------------------------------------------------------------

Public Sub CallByNameZeroArg()
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
End Sub


Public Function CallByNameZeroArgReturnString() As String
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
    CallByNameZeroArgReturnString = "returned String"
End Function

Public Function CallByNameZeroArgReturnObject() As Workbook
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
    Set CallByNameZeroArgReturnObject = ThisWorkbook
End Function

The tstFunctionDelegate standard module

The standard module houses both unit tests and some function which we call via delegates.

Attribute VB_Name = "tstFunctionDelegate"
Option Explicit
Option Private Module

'---------------------------------------------------------------------------------------
' Module    : tstFunctionDelegate
' DateTime  : 06/02/2018 16:26
' Author    : Simon
' Purpose   : Unit tests that also serve as sample calling syntax
'---------------------------------------------------------------------------------------

'---------------------------------------------------------------------------------------
' Procedure : Test_FunctionDelegateFactory_Suite
' DateTime  : 06/02/2018 16:48
' Author    : Simon
' Purpose   : Runs the test suite
'---------------------------------------------------------------------------------------
'
Private Sub Test_FunctionDelegateFactory_Suite()
    Test_FunctionDelegateFactory_FnAppRun
    Test_FunctionDelegateFactory_FnAppRun_DispID0
    Test_FunctionDelegateFactory_FnCallByName
    Test_FunctionDelegateFactory_FnCallByName_DispID0
    Test_AsynchronousWebCall_CompactSyntax
    Test_AsynchronousWebCall_FullerSyntax
End Sub


Private Sub TestOnError(ByVal lErrorNo As Long, ByVal sErrDesc As String)
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    '* this is called by AsynchronousWebCall when handling the OnError event
    Debug.Print "TestOnError", lErrorNo, sErrDesc
End Sub

Private Sub TestOnResponseFinished(ByVal sResponseText As String)
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    '* this is called by AsynchronousWebCall when handling the OnResponseFinished event
    Debug.Print "TestOnResponseFinished"
    Debug.Print Left$(sResponseText, 50)
End Sub

Private Sub Test_FunctionDelegateFactory_FnAppRun()
    '* run this test to test calling function delegate based on application.run
    
    Dim fnFoo As FunctionDelegate
    Set fnFoo = FnFactory.FnAppRun("SubFooZero", False)
    Call fnFoo.Run
    
    
    Dim fnFoo1 As FunctionDelegate
    Set fnFoo1 = FnFactory.FnAppRun("SubFooOne", False)
    Call fnFoo1.Run("hello")
    
    Call fnFoo1("hello") '<--- with DispID=0 we can pull a default member trick and omit Run
     
End Sub
    
Private Sub Test_FunctionDelegateFactory_FnAppRun_DispID0()
    '* run this test to test calling function delegate based on application.run
    '* also testing the omission of Run by using DispID=0
    
    Dim fnFoo As FunctionDelegate
    Set fnFoo = FnFactory.FnAppRun("SubFooZero", False)
    Call fnFoo   '<--- with DispID=0 we can pull a default member trick and omit Run
    
End Sub
    
Private Sub Test_FunctionDelegateFactory_FnCallByName()
    '* run this test to test calling function delegate based on vba.callbyname
    
    
    Dim oTarget As FunctionDelegateTestClass
    Set oTarget = New FunctionDelegateTestClass
    
    Dim fnCBN As FunctionDelegate
    Set fnCBN = FnFactory.FnCallByName(oTarget, "CallByNameZeroArg", VbMethod, False)
    fnCBN.Run
    
    Dim fnCBN2 As FunctionDelegate
    Set fnCBN2 = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnString", VbMethod, False)
    
    Debug.Print fnCBN2.Run
    
    Dim fnCBN3 As FunctionDelegate
    Set fnCBN3 = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnObject", VbMethod, True)
    
    Debug.Print fnCBN3.Run.Name

End Sub

Private Sub Test_FunctionDelegateFactory_FnCallByName_DispID0()
    '* run this test to test calling function delegate based on vba.callbyname
    '* also testing the omission of Run by using DispID=0
    
    Dim oTarget As FunctionDelegateTestClass
    Set oTarget = New FunctionDelegateTestClass
    
    Dim fnFoo As FunctionDelegate
    Set fnFoo = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnObject", VbMethod, True)
    Debug.Print fnFoo().Name '<--- with DispID=0 we can pull a default member trick and omit Run
    
End Sub

Public Sub SubFooZero()
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    Debug.Print "SubFooZero called"
End Sub


Public Sub SubFooOne(v)
    'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
    Debug.Print "SubFooOne called with arg " & v
End Sub



Private Sub Test_AsynchronousWebCall_CompactSyntax()
    '* run this test to see function delegates in action, this has compact syntax
    
    Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
    Set oAsyncXHR = New AsynchronousWebCall
    
    Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", _
                FnFactory.FnAppRun("TestOnError"), _
                FnFactory.FnAppRun("TestOnResponseFinished"))

End Sub

Private Sub Test_AsynchronousWebCall_FullerSyntax()
    '* run this test to see function delegates in action, this has fuller syntax
    
    
    Dim fnOnError As FunctionDelegate
    Set fnOnError = FnFactory.FnAppRun("TestOnError")

    Dim fnOnResponseFinished As FunctionDelegate
    Set fnOnResponseFinished = FnFactory.FnAppRun("TestOnResponseFinished")
    
    Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
    Set oAsyncXHR = New AsynchronousWebCall
    
    
    Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", fnOnError, fnOnResponseFinished)

End Sub

The AsynchronousWebCall class

A more real world use case where we call a web site and pass delegates to functions to be called on error and on completion

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "AsynchronousWebCall"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit

'* Tools->References
'*     WinHttp    Microsoft WinHTTP Services, version 5.1     C:WINDOWSsystem32winhttpcom.dll


Private WithEvents moXHR As WinHttp.WinHttpRequest
Attribute moXHR.VB_VarHelpID = -1

Private mfnFunctionDelegateOnError As FunctionDelegate
Private mfnFunctionDelegateOnResponseFinished As FunctionDelegate

'---------------------------------------------------------------------------------------
' Procedure : RunAsynchronous
' DateTime  : 06/02/2018 16:20
' Author    : Simon
' Purpose   :
'---------------------------------------------------------------------------------------
' Arguments :
'    sHttpMethod                  : one of {GET, PUT, POST, DELETE}
'    sURL                         : the web address
'    fnOnErrorDelegate            : the delegate of the function we want to call on an error
'    fnOnResponseFinishedDelegate : the delegate of the function we want to call on completion
'
Public Sub RunAsynchronous(ByVal sHttpMethod As String, ByVal sURL As String, _
        ByVal fnOnErrorDelegate As FunctionDelegate, ByVal fnOnResponseFinishedDelegate As FunctionDelegate)

    Set mfnFunctionDelegateOnError = fnOnErrorDelegate
    Set mfnFunctionDelegateOnResponseFinished = fnOnResponseFinishedDelegate

    Set moXHR = New WinHttp.WinHttpRequest
    moXHR.Open sHttpMethod, sURL, True
    moXHR.send
    

End Sub



'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnError
' DateTime  : 06/02/2018 16:16
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383929(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnError(ByVal ErrorNumber As Long, ByVal ErrorDescription As String)
    'Debug.Print "moXHR_OnError"
    If Not mfnFunctionDelegateOnError Is Nothing Then
        mfnFunctionDelegateOnError.Run ErrorNumber, ErrorDescription
    End If
    
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseDataAvailable
' DateTime  : 06/02/2018 16:16
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383941(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseDataAvailable(Data() As Byte)
    'Debug.Print "moXHR_OnResponseDataAvailable"
    '* not interested
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseFinished
' DateTime  : 06/02/2018 16:17
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383946(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseFinished()
    'Debug.Print "moXHR_OnResponseFinished"
    If Not mfnFunctionDelegateOnResponseFinished Is Nothing Then
        mfnFunctionDelegateOnResponseFinished.Run moXHR.responseText
    End If
    
End Sub

'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseStart
' DateTime  : 06/02/2018 16:17
' Author    : Simon
' Purpose   : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383954(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseStart(ByVal Status As Long, ByVal ContentType As String)
    'Debug.Print "moXHR_OnResponseStart"
    '* not interested
End Sub

VBA - Detecting external references to workbook

So a great question came up on StackOverflow about detecting external references to workbooks. Like most I thought the Trace precendents toolbar button would get one there but one has too double click on an external link icon. Equally, the Range.Precedents property does not report cells on an external workbook. So initially I was stumped.

The solution is examine a Workbooks LinkSources and then use that as a search term (you'll need to chop the path and enclose in square brackets). Here is the code.


Option Explicit

'---------------------------------------------------------------------------------------
' Procedure : Investigate
' DateTime  : 06/02/2018 14:40
' Author    : Simon
' Purpose   : Start execution here.  There is some setup code
'---------------------------------------------------------------------------------------
' Arguments :
'    arg1      : arg1 description
'
Sub Investigate()

    '**************************************************
    ' START of Experiment setup code
    '**************************************************
    Dim wb1 As Excel.Workbook, wb2 As Excel.Workbook

    GetOrCreateMyTwoWorbooks "Book1", "SimonSub1", wb1, "Book2", "SimonSub2", wb2


    wb1.Worksheets(1).Range("a1").Formula = "=2^4"
    
    
    wb2.Worksheets(1).Range("a1").Formula = "=2^2"
    wb2.Worksheets(1).Range("b1").Formula = "=3^2"
    wb2.Worksheets(1).Range("a2").FormulaR1C1 = "=[" & wb1.Name & "]Sheet1!R1C1/r1c1*r1c2"

    '**************************************************
    ' END of Experiment setup code
    '**************************************************

    '**************************************************
    '* now the real logic begins
    '**************************************************
    
    Dim dicLinkSources As Scripting.Dictionary
    Set dicLinkSources = LinkSources(wb2)
    
    '* get all the cells containing formulae in the worksheet we're interested in
    Dim rngFormulaCells As Excel.Range
    Set rngFormulaCells = wb2.Worksheets(1).UsedRange.SpecialCells(xlCellTypeFormulas)
    
    '* set up results container (one could report as we find them but I like to collate)
    Dim dicExternalWorksheetPrecedents As Scripting.Dictionary
    Set dicExternalWorksheetPrecedents = New Scripting.Dictionary
    
    '* loop throught the subset of cells on the worksheet that have formulae
    Dim rngFormulaCellsLoop As Excel.Range
    For Each rngFormulaCellsLoop In rngFormulaCells
    
        Dim sFormula As String
        sFormula = rngFormulaCellsLoop.Formula  '* I like a copy in my locals window
        
        '* search for all the link sources (experiment has only one, chance are you'll have many)
        Dim vSearchLoop As Variant
        For Each vSearchLoop In dicLinkSources.Items
            If VBA.InStr(1, sFormula, vSearchLoop, vbTextCompare) > 0 Then
            
                '* we found one, add to collated results
                dicExternalWorksheetPrecedents.Add wb2.Name & "!" & wb2.Worksheets(1).Name & "!" & rngFormulaCellsLoop.Address, vSearchLoop
            
            End If
        Next vSearchLoop
        
    Next
    
    '*print collated results
    Dim lResultLoop As Long
    For lResultLoop = 0 To dicExternalWorksheetPrecedents.Count - 1
        Debug.Print "Cell at " & dicExternalWorksheetPrecedents.Keys()(lResultLoop) & " has external workbook source of " & dicExternalWorksheetPrecedents.Items()(lResultLoop)
    
    Next lResultLoop
    
    
    Stop
End Sub

'---------------------------------------------------------------------------------------
' Procedure : LinkSources
' DateTime  : 06/02/2018 14:38
' Author    : Simon
' Purpose   : To acquire list of link sources and more importantly the search term
'             we're going to see to look for external workbooks
'---------------------------------------------------------------------------------------
' Arguments :
'   [in] wb         : The workbook we want report on
'   [out,retval]    : returns a dictionary with the lik sources in the keys and search term in item
'
Function LinkSources(ByVal wb As Excel.Workbook) As Scripting.Dictionary
 
    Static fso As Object
    If fso Is Nothing Then Set fso = VBA.CreateObject("Scripting.FileSystemObject")

    Dim dicLinkSources As Scripting.Dictionary
    Set dicLinkSources = New Scripting.Dictionary
    
    Dim vLinks As Variant
    vLinks = wb.LinkSources(XlLink.xlExcelLinks)
    
    If Not IsEmpty(vLinks) Then
        Dim lIndex As Long
        For lIndex = LBound(vLinks) To UBound(vLinks)
        
            Dim sSearchTerm As String
            sSearchTerm = ""
            
            If fso.FileExists(vLinks(lIndex)) Then
                Dim fil As Scripting.file
                Set fil = fso.GetFile(vLinks(lIndex))
                    
                '* this is what we'll search for in the cell formulae
                sSearchTerm = "[" & fil.Name & "]"
                
            End If
        
            dicLinkSources.Add vLinks(lIndex), sSearchTerm
        
        Next lIndex
    End If
    Set LinkSources = dicLinkSources
End Function


'*****************************************************************************************************************
'                                         __                                __
'_____  ______ ___________ ____________ _/  |_ __ __  ______   ______ _____/  |_ __ ________
'\__  \ \____ \\____ \__  \\_  __ \__  \\   __\  |  \/  ___/  /  ___// __ \   __\  |  \____ \
' / __ \|  |_> >  |_> > __ \|  | \// __ \|  | |  |  /\___ \   \___ \\  ___/|  | |  |  /  |_> >
'(____  /   __/|   __(____  /__|  (____  /__| |____//____  > /____  >\___  >__| |____/|   __/
'     \/|__|   |__|       \/           \/                \/       \/     \/           |__|
'
'*****************************************************************************************************************
'* this is just something to setup the experiment, you won't need this hence the big banner  :)
'*
Public Sub GetOrCreateMyTwoWorbooks(ByVal sWbName1 As String, ByVal sSubDirectory1 As String, ByRef pwb1 As Excel.Workbook, _
                                    ByVal sWbName2 As String, ByVal sSubDirectory2 As String, ByRef pwb2 As Excel.Workbook)

    Static fso As Object
    If fso Is Nothing Then Set fso = VBA.CreateObject("Scripting.FileSystemObject")
    
    On Error Resume Next
    Set pwb1 = Application.Workbooks.Item(sWbName1)
    Set pwb2 = Application.Workbooks.Item(sWbName2)
    On Error GoTo 0
    
    If pwb1 Is Nothing Then
        Set pwb1 = Application.Workbooks.Add
        
        Dim sSubDir1 As String
        sSubDir1 = fso.BuildPath(Environ$("tmp"), sSubDirectory1)
        
        If Not fso.FolderExists(sSubDir1) Then fso.CreateFolder (sSubDir1)
        
        Dim sSavePath1 As String
        sSavePath1 = fso.BuildPath(sSubDir1, sWbName1)
        
        pwb1.SaveAs sSavePath1
    End If
    
    If pwb2 Is Nothing Then
        Set pwb2 = Application.Workbooks.Add
        
        Dim sSubDir2 As String
        sSubDir2 = fso.BuildPath(Environ$("tmp"), sSubDirectory2)
        
        If Not fso.FolderExists(sSubDir2) Then fso.CreateFolder (sSubDir2)
        
        
        Dim sSavePath2 As String
        sSavePath2 = fso.BuildPath(sSubDir2, sWbName2)
        
        pwb2.SaveAs sSavePath2
    End If
    

End Sub



Monday, 5 February 2018

VBA - ProgID - what is the current version

So a SO question arose about a non-installed version of MSXML2.ServerXMLHTTP. This made me wonder why not poke around in the registry to try and find all instances of MSXML2.ServerXMLHTTP in my registry, the results are given in Appendix A. It showed that version 4 is missing just like for the questioner. The registry sweep shows versions 3.0, 5.0 ,6.0 available.

What is really curious is there is a registry key

Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP\CurVer

whose default value is

Msxml2.ServerXMLHTTP.3.0

This means if one writes the following code using late binding and no version in the prog id to instantiate a Msxml2.ServerXMLHTTP then one gets a 3.0 version and not a 6.0 version. The rationale is given in this MSDN blog.

Sub CreateXHR()
    Dim oXHR As Object
    Set oXHR = VBA.CreateObject("Msxml2.ServerXMLHTTP")
End Sub

We can write some code to query the registry to tell us what the non versioned prog id actually returns ...

Sub TestCurVersion()

    Debug.Print CurVersion("Msxml2.ServerXMLHTTP")
    '* for me returns Msxml2.ServerXMLHTTP.3.0
    
    Debug.Print CurVersion("Excel.Application")
    '* for me returns Excel.Application.15
    
End Sub


Function CurVersion(ByVal sClass As String) As String
    Const HKLM As Long = &H80000002
    Dim oWMIReg As Object
    
    Set oWMIReg = GetObject("winmgmts:{impersonationLevel=impersonate}!\\" & _
             ".\root\default:StdRegProv")
    Dim sReturnString As String
    oWMIReg.GetStringValue HKLM, "SOFTWARE\Classes\" & sClass & "\CurVer", "", sReturnString
    CurVersion = sReturnString
End Function

So unless one wants to rewrite the registry keys to change the current version to version 6.0 then I recommend supplying the string "Msxml2.ServerXMLHTTP.6.0" thus

Sub CreateXHR60()
    Dim oXHR As Object
    Set oXHR = VBA.CreateObject("Msxml2.ServerXMLHTTP.6.0")
End Sub

Simulating the ProgID resolution

With COM the resolution of the ProgID take places by calling CLSIDFromString in OLE32.dll and we can write code to simulate this and then go lookup in the registry


Option Explicit

Private Type GUID
    Data1 As Long
    Data2 As Integer
    Data3 As Integer
    Data4(7) As Byte
End Type

Private Declare Function OLE32_CLSIDFromString Lib "OLE32" _
    Alias "CLSIDFromString" (ByVal lpszCLSID As String, pclsid As GUID) As Long
    
Public Function VBA_CLSIDFromString(ByVal sClass As String) As String
    
    Dim rclsid As GUID
    Dim hr As Long
    hr = OLE32_CLSIDFromString(StrConv(sClass, vbUnicode), rclsid)
    If hr <> 0 Then Err.Raise hr

    Dim sHexCLSID As String
    
    sHexCLSID = "{" & PadHex(rclsid.Data1, 8) & "-" & PadHex(rclsid.Data2, 4) & "-" & _
                PadHex(rclsid.Data3, 4) & "-"
    
    Dim lData4Loop As Long
    For lData4Loop = 0 To 7
        If lData4Loop = 2 Then sHexCLSID = sHexCLSID & "-"
        sHexCLSID = sHexCLSID & PadHex(rclsid.Data4(lData4Loop), 2)
    
    Next lData4Loop
    
    VBA_CLSIDFromString = sHexCLSID & "}"
    Debug.Assert Len(VBA_CLSIDFromString) = 38
End Function

Private Function PadHex(ByVal lNum As Long, ByVal lDigits As Long) As String
    PadHex = Right(String(lDigits, "0") & Hex$(lNum), lDigits)
End Function

Public Function WMI_COMClassVersion(ByVal sClsId As String) As String
    Dim oWMIReg As Object
    Set oWMIReg = GetObject("winmgmts:{impersonationLevel=impersonate}!\" & _
             ".rootdefault:StdRegProv")
    Dim sVersionString As String
    oWMIReg.GetStringValue &H80000002, "SOFTWAREClassesCLSID" & sClsId & "Version", "", sVersionString
    WMI_COMClassVersion = sVersionString

End Function


Public Function WhatVersionOfProgID(sClass As String) As String
    
    Dim sClsId As String
    sClsId = VBA_CLSIDFromString(sClass)
    
    WhatVersionOfProgID = WMI_COMClassVersion(sClsId)
    Exit Function
End Function

Private Sub TestWhatVersionOfProgID()
    Debug.Assert WhatVersionOfProgID("MSXML2.ServerXMLHTTP") = "3.0"
End Sub


Appendix A

Sweeping my registry for instances of MSXML2.ServerXMLHTTP turned up the following


Computer\HKEY_CLASSES_ROOT\CLSID\{88d96a0b-f192-11d4-a65f-0040963251e5}\ProgID
Computer\HKEY_CLASSES_ROOT\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\ProgID
Computer\HKEY_CLASSES_ROOT\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\VersionIndependentProgID
Computer\HKEY_CLASSES_ROOT\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\ProgID
Computer\HKEY_CLASSES_ROOT\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\VersionIndependentProgID
Computer\HKEY_CLASSES_ROOT\Wow6432Node\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\VersionIndependentProgID
Computer\HKEY_CLASSES_ROOT\Wow6432Node\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{88d96a0b-f192-11d4-a65f-0040963251e5}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\VersionIndependentProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP.3.0
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP.5.0
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP.6.0
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Msxml2.ServerXMLHTTP\CurVer
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{88D969EB-F192-11D4-A65F-0040963251E5}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{88d96a0b-f192-11d4-a65f-0040963251e5}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\VersionIndependentProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Classes\WOW6432Node\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\VersionIndependentProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{88D969EB-F192-11D4-A65F-0040963251E5}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{88d96a0b-f192-11d4-a65f-0040963251e5}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{AFB40FFD-B609-40A3-9828-F88BBE11E4E3}\VersionIndependentProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\ProgID
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID\{AFBA6B42-5692-48EA-8141-DC517DCF0EF1}\VersionIndependentProgID


Links

Wednesday, 31 January 2018

VBA - Shell - Find - Command Line - Recursively find in files

Summary: Find, command line, searches for a specific string of text in a file or files. However, Find is limited to one folder, if you want to scan a folder structure recursively you'll need this VBA program.

So, I had a need to scan a heap of C++ source files looking for some relevant code sample. The command line program Find is very useful but generates too much output that needs editing, also it only runs on one folder at a time, so we need to write some code to loop through a list of folders. The list of folders itself is the result of shelling to the command line so there is some code ReadDirList_FoldersOnly() to parse a directory listing.

Also there is a file filters feature where one specifies a list of acceptable file extensions, the code given filters for *.cpp and *,.h files only because it is searching for C++ source files.

The code can take some time to run so I've added a status bar percentage progress counter. Because of disk activity there is little point trying to add multi-tasking or multi-threading.

The code is given below but here is some sample output

---------- C:\PROGRA~2\MICROS~4\2017\COMMUN~1\VC\TOOLS\MSVC\1412~1.258\ATLMFC\INCLUDE\AFXDOCOB.H
    BEGIN_INTERFACE_PART(OleDocument, IOleDocument)
    BEGIN_INTERFACE_PART(OleDocumentView, IOleDocumentView)
---------- C:\PROGRA~2\MICROS~4\2017\COMMUN~1\VC\TOOLS\MSVC\1412~1.258\ATLMFC\INCLUDE\AFXOLE.H
    BEGIN_INTERFACE_PART(OleDocumentSite, IOleDocumentSite)

Option Explicit
Option Private Module

Sub TestShellRecursiveDirectory2()
    Application.StatusBar = False
    Dim dicFolders As Scripting.Dictionary
    Set dicFolders = New Scripting.Dictionary
    'ShellRecursiveDirectory "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\", dicFolders
    ShellRecursiveDirectory "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.12.25827\atlmfc\include", dicFolders



    'Debug.Assert dicFolders.Count = 6467

    Dim sFindText As String
    sFindText = "IOLEDocument"

    Dim sTopFolder As String
    'sTopFolder = dicFolders.Keys(0)
    'dicFolders.RemoveAll
    'dicFolders.Add sTopFolder, 0

    Dim dicFindings As Scripting.Dictionary
    Set dicFindings = New Scripting.Dictionary

    RunFindInFolders dicFolders, sFindText, dicFindings, Array("cpp", "h")

    'Debug.Print Join(dicFindings.Items, vbNewLine)

    Call CreateObject("Scripting.FileSystemObject").CreateTextFile("n:\foo.txt").Write(Join(dicFindings.Items, vbNewLine))
    Application.StatusBar = False
End Sub


Sub RunFindInFolders(ByVal dicFolders As Scripting.Dictionary, ByVal sFindText As String, _
                                ByRef pdicFindings As Scripting.Dictionary, ByVal vFileFilters As Variant)

    If pdicFindings Is Nothing Then Set pdicFindings = New Scripting.Dictionary

    Dim lCounter As Long: lCounter = 0
    Dim lTotal As Long: lTotal = dicFolders.Count

    Dim vFolderLoop As Variant
    For Each vFolderLoop In dicFolders.Keys
        lCounter = lCounter + 1
        Dim lPercent As Long
        lPercent = CLng((lCounter / lTotal) * 100)

        If lPercent Mod 5 = 0 Then
            Application.StatusBar = "Complete " & lPercent & "%"

        End If

        DoEvents
        RunFindInFolder vFolderLoop, sFindText, pdicFindings, vFileFilters

    Next vFolderLoop

End Sub

Sub TestRunFindInFolder()
    Dim dicFindings As Scripting.Dictionary
    RunFindInFolder "C:\Users\Simon\DOWNLO~1\RUBBER~1\Rubberduck-next", "ole", dicFindings, Empty

    Debug.Print VBA.Join(dicFindings.Items, vbNewLine)
End Sub

Sub RunFindInFolder(ByVal sFolder As String, ByVal sFindText As String, _
                    ByRef pdicFindings As Scripting.Dictionary, ByVal vFileFilters As Variant)
    Static fso As New Scripting.FileSystemObject
    If pdicFindings Is Nothing Then Set pdicFindings = New Scripting.Dictionary

    If fso.FolderExists(sFolder) Then

        Dim sFullFileFilter As String
        sFullFileFilter = fso.BuildPath(sFolder, "*.*")
        '#sFullFileFilter = fso.BuildPath(sFolder, sFileFilter)



        Dim sTempFile As String, sFullTempFile As String
        TempFile sFullFileFilter, sTempFile, sFullTempFile, ".txt"

        If fso.FileExists(sFullTempFile) Then fso.DeleteFile sFullTempFile
        Debug.Assert Not fso.FileExists(sFullTempFile)


        Dim sCmd As String
        sCmd = Environ$("comspec") & " /C Find """ & sFindText & """ " & sFullFileFilter & " /I > " & sFullTempFile
        'find "Ole" *.*  /I > %TEMP%\ole_find.txt

        Dim oWshShell As IWshRuntimeLibrary.WshShell
        Set oWshShell = New IWshRuntimeLibrary.WshShell

        Dim lProc As Long
        lProc = oWshShell.Run(sCmd, 0, True)

        Debug.Assert fso.FileExists(sFullTempFile)

        Dim dicLines As Scripting.Dictionary
        Set dicLines = New Scripting.Dictionary


        Dim txtIn As Scripting.TextStream
        Set txtIn = fso.OpenTextFile(sFullTempFile)

        Do While Not txtIn.AtEndOfStream
            DoEvents
            Dim sLine As String
            sLine = txtIn.ReadLine

            dicLines.Add dicLines.Count, sLine


        Loop
        txtIn.Close
        Set txtIn = Nothing

        Call ReadFindingsFile(sFolder, dicLines, pdicFindings, vFileFilters)

    End If

End Sub

Sub ReadFindingsFile(ByVal sFolder As String, ByVal dicLines As Scripting.Dictionary, _
                ByRef pdicFindings As Scripting.Dictionary, ByVal vFileFilters As Variant)
    Static fso As New Scripting.FileSystemObject


    If pdicFindings Is Nothing Then Set pdicFindings = New Scripting.Dictionary

    'Dim vSplit 'As String
    'vSplit = Split(sFindings, vbNewLine)

    Dim sPrefix As String
    sPrefix = "---------- " & sFolder

    Dim dicTemp As Scripting.Dictionary
    Set dicTemp = New Scripting.Dictionary

    Dim vLoop As Variant
    For Each vLoop In dicLines.Items
        DoEvents
        
            Dim sCurrentFile As String
        
        '* do we have a file header, if so capture the filename
        '* and check if it has the right file extension
        If StartsWith(sPrefix, vLoop) Then
            Dim sNextFile As String
            sNextFile = Trim(Mid(vLoop, Len("---------- "), Len(vLoop)))
            
            Dim bCaptureOutput As Boolean
            bCaptureOutput = FileExtensionMatch(sNextFile, vFileFilters)
            
            '* copy over anything
            MoveOverItemsWithHeader sCurrentFile, dicTemp, pdicFindings
            
            sCurrentFile = sNextFile
        Else
            '* it's not a header line it is an output line
            If bCaptureOutput Then
                If Len(Trim(vLoop)) > 0 Then
                    dicTemp.Add dicTemp.Count, vLoop
                End If
            End If
        
        End If
        
        
        
'        If StartsWith(sPrefix, vLoop) Then
'
'            Dim sFile As String
'
'            Dim sNextFile As String
'            sNextFile = Trim(Mid(vLoop, Len("---------- "), Len(vLoop)))
'
'
'
'            Dim sFileName As String
'            sFileName = Trim(Mid(vLoop, Len("---------- "), Len(vLoop)))
'
'            'Debug.Assert fso.FileExists(sFileName)
'
'
'
'            If FileExtensionMatch(sFileName, vFileFilters) Then 'UCase$(fil.Name) Like UCase$(sFileFilter)
'                'If Len(sFile) > 0 Then
'                    MoveOverItemsWithHeader sFile, dicTemp, pdicFindings
'                'End If
'            Else
'                dicTemp.RemoveAll
'            End If
'
'            sFile = sNextFile
'
'        Else
'            If Len(Trim(vLoop)) > 0 Then
'                dicTemp.Add dicTemp.Count, vLoop
'
'            End If
'
'        End If
    Next vLoop
    'Stop

    '* copy over anything
    MoveOverItemsWithHeader sCurrentFile, dicTemp, pdicFindings


'    If FileExtensionMatch(sFileName, vFileFilters) Then
'        MoveOverItemsWithHeader sFile, dicTemp, pdicFindings
'    Else
'        dicTemp.RemoveAll
'    End If


    Debug.Assert dicTemp.Count = 0

End Sub

Private Function FileExtensionMatch(ByVal sFileName As String, _
                            ByVal vFileFilters As Variant) As Boolean
    Static fso As New Scripting.FileSystemObject

    If Len(sFileName) > 0 Then

        If Not fso.FileExists(sFileName) Then
            FileExtensionMatch = False
        Else

            If IsEmpty(vFileFilters) Then
                FileExtensionMatch = True
            Else

                Debug.Assert fso.FileExists(sFileName)

                Dim fil As Scripting.File
                Set fil = fso.GetFile(sFileName)

                Dim vSplitFileName As Variant
                vSplitFileName = Split(fil.Name, ".")

                Dim sFileExt As String
                sFileExt = vSplitFileName(UBound(vSplitFileName))

                Dim bFilterMatch As Boolean
                FileExtensionMatch = (VBA.InStr(1, _
                            "|" & Join(vFileFilters, "|") & "|", _
                            "|" & sFileExt & "|", _
                            vbTextCompare) > 0)

            End If
        End If
    End If

End Function

Private Function MoveOverItemsWithHeader(ByVal sFile As String, ByVal dicFrom As Scripting.Dictionary, _
            ByVal dicTo As Scripting.Dictionary)

    If Len(sFile) > 0 Then
        If dicFrom.Count > 0 Then
            Debug.Print "---------- " & sFile
            dicTo.Add dicTo.Count, "---------- " & sFile
    
            Dim vCopyLoop As Variant
            For Each vCopyLoop In dicFrom.Items
                dicTo.Add dicTo.Count, vCopyLoop
                Debug.Print vCopyLoop
            Next
    
            dicFrom.RemoveAll
        End If
    End If

End Function


'Private Sub TestStartsWith()
'    Debug.Assert StartsWith("Hell", "Hello world")
'    Debug.Assert Not StartsWith("Hell", "He")
'    Debug.Assert Not StartsWith("Hell", "Foob")
'End Sub

Function StartsWith(ByVal sPrefix As String, ByVal sTest As String) As Boolean
    If LenB(sPrefix) <= LenB(sTest) Then
        StartsWith = (VBA.StrComp(sPrefix, Left$(sTest, Len(sPrefix)), vbTextCompare) = 0)
    End If
End Function

Function TempFile(ByVal sURL As String, ByRef psTempFile As String, ByRef psFullTempFile As String, _
                                                            Optional ByVal sSuffix As String = ".txt")


    Static dict As New Scripting.Dictionary
    Static fso As New Scripting.FileSystemObject

    Dim lProcessUniqueId As Long
    lProcessUniqueId = UniqueProcessId

    Dim sTempFile As String
    psTempFile = CStr(lProcessUniqueId) & CStr(dict.HashVal(sURL)) & sSuffix

    Dim sFullTempFile As String
    psFullTempFile = fso.BuildPath(Environ$("TEMP"), psTempFile)


End Function

Function UniqueProcessId() As Long
    On Error Resume Next
    UniqueProcessId = CallByName(Application, "Hwnd", VbGet)
End Function

Sub ShellRecursiveDirectory(ByVal sStartFolder As String, ByRef pdicFolders As Scripting.Dictionary)

    Static fso As New Scripting.FileSystemObject


    If Not fso.FolderExists(sStartFolder) Then
        Err.Raise vbObjectError, , "#Folder sStartFolder '" & sStartFolder & "' does not exist!"
    Else
        Dim fld As Scripting.Folder
        Set fld = fso.GetFolder(sStartFolder)

        Dim oWshShell As IWshRuntimeLibrary.WshShell
        Set oWshShell = New IWshRuntimeLibrary.WshShell



        Dim sTempFile As String, sFullTempFile As String
        TempFile sStartFolder, sTempFile, sFullTempFile, ".txt"

        If fso.FileExists(sFullTempFile) Then fso.DeleteFile sFullTempFile
        Debug.Assert Not fso.FileExists(sFullTempFile)

        Dim sCmdSpec As String
        sCmdSpec = Environ("comspec") & " /C " '* /C is required to run remainder of command

        Dim sCmdLine As String
        sCmdLine = sCmdSpec & " dir " & fld.ShortPath & "\*.* /s  > " & sFullTempFile


        Dim lProc As Long
        lProc = oWshShell.Run(sCmdLine, 0, True)

        Debug.Assert fso.FileExists(sFullTempFile)

        Dim dic As Scripting.Dictionary

        ReadDirList_FoldersOnly sFullTempFile, pdicFolders
        'Stop
    End If


End Sub


Function ReadDirList_FoldersOnly(ByVal sPipedOutputPath As String, ByRef pdicFolders As Scripting.Dictionary)

    Static fso As New Scripting.FileSystemObject
    If fso.FileExists(sPipedOutputPath) Then

        Dim txtIn As Scripting.TextStream
        Set txtIn = fso.OpenTextFile(sPipedOutputPath)

        Set pdicFolders = New Scripting.Dictionary

        Dim sLine As String
        sLine = txtIn.ReadLine
        While Not txtIn.AtEndOfStream
            DoEvents

            Dim sDirectory As String
            Dim bIsDirectoryHeader As Boolean
            bIsDirectoryHeader = IsDirectoryHeader(sLine, sDirectory)

            If IsTrailerLine(sLine) Then

                If Not pdicFolders.Exists(sDirectory) Then pdicFolders.Add sDirectory, 0
            End If

            sLine = txtIn.ReadLine
        Wend

        txtIn.Close
        Set txtIn = Nothing
    End If

End Function

Function IsTrailerLine(ByVal sLine As String) As Boolean
    If (VBA.InStr(1, sLine, "File(s)", vbTextCompare) > 0) Then
        IsTrailerLine = True
    End If
End Function

Function IsDirectoryHeader(ByVal sLine As String, ByRef psDirectory As String) As Boolean
    If VBA.InStr(1, sLine, " Directory of ", vbTextCompare) > 0 Then
        psDirectory = Trim(Mid$(sLine, 15))
        IsDirectoryHeader = True
    End If
End Function


Saturday, 27 January 2018

VBA - XMLHTTP60 - Tricky event handling

Summary: XMLHTTP60 does not have any standard VBA events but by adding a class and pulling a trick in a text editor we can track events.

So neither MSXML2.XMLHTTP60 nor MSXML2.ServerXMLHTTP60 have any standard VBA events that can be trapped by declaring a variable with the WithEvents keyword. This contrasts with the WinHttp.WinHttpRequest class (see prior blog post for example code). However, we can still trap events but we have to pull a trick or two along the way. The official tutorial from Microsoft is given here, Microsoft - Use the onReadyStateChange Property (Visual Basic)

One needs to create a VBA class to handle the events. I give the source next but this is exported file source to be copied into a text editor such as Notepad, saved and then imported in the VBA IDE. Do not cut and paste directly into the VBA IDE. This is because of a line of hidden source code which is given here

Attribute Item.VB_UserMemId = 0

This line will disappear from view once the class module is imported. [In case you're interested in what it does, setting UserMemId=0 makes it the default method which means you can call it whatever you want because the caller will ask for it by its Dispatch Id (0), this is an IDispatch trick]. The top 9 lines also disappear but that is standard behaviour.

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "XHRSink"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit

' In text editor, need to put "Attribute Item.VB_UserMemId = 0" on line underneath next line, 
' save and then import into VBA IDE, this sets the default method of this class
' Then we set an instance of this class to the OnReadyStateChange property of
' MSXML2.XMLHTTP60 or MSXML2.ServerXMLHTTP60 to get events
' The line "Attribute Item.VB_UserMemId = 0" will DISAPPEAR from view once imported
Sub OnReadyStateChange()
 Attribute Item.VB_UserMemId = 0

    Debug.Print goXHR.readyState
    If goXHR.readyState = 4 Then
        Debug.Print "sink code handling result"
        Debug.Print goXHR.responseText
    End If
End Sub

So now that the above class is imported, it should read XHRSink in the project folder, we can use it when setting the OnReadyStateChange [N.B. we don't use the Set keyword, this is not a typo!] of XMLHTTP60 (or ServerXMLHTTP60) . We are calling a slow and chunky web service built in a prior blog post.

Option Explicit

'* Tools->References
'MSXML2      Microsoft XML, v6.0      C:\Windows\SysWOW64\msxml6.dll


Global goXHR As MSXML2.XMLHTTP60

'https://msdn.microsoft.com/en-us/library/ms757030(v=vs.85).aspx


Public Sub HttpGet()
    On Error GoTo ErrHandler

    Randomize
    Debug.Print String(10, vbNewLine)

    Dim bAsync As Boolean
    'bAsync = True
    bAsync = False

    Set goXHR = New MSXML2.XMLHTTP60
    
    '* need random number in query parameters to make url unique and stop caching
    goXHR.Open bstrMethod:="GET", bstrURL:="http://localhost:34957/slowAndChunkyWebService?chunkCount=5&random=" & Rnd(1), varAsync:=bAsync
    
    Dim oSink As XHRSink
    Set oSink = VBA.IIf(bAsync, New XHRSink, Nothing)
    
    goXHR.OnReadyStateChange = oSink
    
    
    goXHR.send
    
    Debug.Print "send called with bAsync=" & bAsync
    If bAsync = False Then
        Debug.Print "main code handling result with bAsync=" & bAsync
        If goXHR.readyState = 4 Then Debug.Print goXHR.responseText
    End If
    
SingleExit:
    Exit Sub
ErrHandler:
    Debug.Print "Error (" & Err.Number & ") " & Err.Description
    Stop
    Resume
    
End Sub

To experiment swap the commenting on bAsync = True and bAsync = False. The code reports to the Immediate window what it is doing. Sadly no option to chunk the response is available (not that I know of). Here is some sample reported output when bAsync = True.

send called with bAsync=True
 2 
 3 
 4 
sink code handling result
foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar...

Ok, so that works but how extendable is it? What happens with multiple requests? So for multiple requests one would need to upgrade the XHRSink class with an identifier to tie it back to the source; this is a bit poor but not impossible.

Final thoughts. The limited event handling for XMLHTTP60 needs to be compared and contrasted with (a) WinHttpRequest (b) that found in modern browsers facilitated by jQuery and (c) that found on the web servers such as Node.js I would be very tempted to write web service client code in Node.js and then allow VBA to call in to the finalised and processed results.

VBA - WinHttpRequest - No asynchronous chunks

Summary: WinHttp.WinHttpRequest is yet another Http request class that has some features such as chunking, sadly it won't chunk asynchronously.

So someone asked a good question of SO about chunking data from a web service, the questioner complained about missing data. With the object browser it can be seen that WinHttp.WinHttpRequest supports events which can be used to trap chunks of data. It looked promising. However, after some experimentation it does not seem possible to have asynchronous chunking. One can have asynchronous request and receive the whole request or one can have a chunked synchronous request. One cannot have both chunked and asynchronous.

I give code below some that others can check my results. I tested against a Node.js slow and chunky web service from a prior blog post. In order to sink events it is necessary to use the WithEvents keyword in a class module. Here is the class module which I called WHRChunked

Option Explicit

'* Tools->References
'WinHttp        Microsoft WinHTTP Services, version 5.1          C:\WINDOWS\system32\winhttpcom.dll

Private WithEvents moWHR As WinHttp.WinHttpRequest

Public msBufferedResponse As String
Public mbFinished As Boolean

Private Const mbDEFAULT_DEBUG  As Boolean = True
Public mvDebug As Variant

Public Property Get bDebug() As Boolean
    If IsEmpty(mvDebug) Then mvDebug = mbDEFAULT_DEBUG
    
    bDebug = mvDebug
End Property
Public Property Let bDebug(ByVal bRHS As Boolean)
    mvDebug = bRHS
End Property

Public Sub HttpGet(ByVal sURL As String, bAsync As Boolean)
    On Error GoTo ErrHandler

    Set moWHR = New WinHttp.WinHttpRequest
    
    
    mbFinished = False
    msBufferedResponse = ""
    
    moWHR.Open Method:="GET", URL:=sURL, async:=bAsync
    
    moWHR.send
    Debug.Print "send called with bAsync=" & bAsync
SingleExit:
    Exit Sub
ErrHandler:
    Debug.Print "Error (" & Err.Number & ") " & Err.Description
    Stop
    Resume
    
End Sub


Private Sub moWHR_OnError(ByVal ErrorNumber As Long, ByVal ErrorDescription As String)
    Debug.Print "moWHR_OnError"

End Sub

Private Sub moWHR_OnResponseDataAvailable(Data() As Byte)
    
    Dim sThisChunk As String
    sThisChunk = StrConv(Data(), vbUnicode)
    
    Debug.Print "moWHR_OnResponseDataAvailable (" & Len(sThisChunk) & ")"
    
    msBufferedResponse = msBufferedResponse & sThisChunk
    
End Sub

Private Sub moWHR_OnResponseFinished()
    Debug.Print "moWHR_OnResponseFinished"
    mbFinished = True
End Sub

Private Sub moWHR_OnResponseStart(ByVal Status As Long, ByVal ContentType As String)

    Dim v
    v = VBA.Split(moWHR.getAllResponseHeaders, vbNewLine)
    Debug.Print "moWHR_OnResponseStart"

End Sub

And we need some code in a standard module to call into the class, remember this needs the web service from previous blog post.

Option Explicit

Sub Test()
    Dim oWHRChunked As WHRChunked
    Set oWHRChunked = New WHRChunked
    
    oWHRChunked.HttpGet "http://localhost:34957/slowAndChunkyWebService?chunkCount=2", True
    'oWHRChunked.HttpGet "http://localhost:34957/slowAndChunkyWebService?chunkCount=2", False
    
    While oWHRChunked.mbFinished = False
        DoEvents
    Wend
    
    Debug.Print oWHRChunked.msBufferedResponse


End Sub

So to experiment simply swap the above commented line for the other to see the different effects, the evidence is posted to the Immediate window using Debug.Print .

Final thoughts, I'm disappointed by this finding I hope I have it wrong. I must do a comparison table of the different features between MSXML2.XMLHTTP60, MSXML2.ServerXMLHTTP60 and WinHttpRequest.

Node.js - Slow and chunky webservice (deliberately slow)

So I want to test event handling of a library available for VBA developers but to test it I need to first build a web server that is deliberately slow and chunky. I blogged a simple web service previously that chunked a request (taking each chunk to process a portion of the post body). This time I want to chunk the response.

We use setTimeout (just like a browser) to schedule execution of a block of code. We are not reading a file or anything we are simply sending text strings back. We are splitting this out into schedule chunks to fit nicely with Node.js asynchronous non-blocking interleaved execution pattern.

The node.js libraries used are http and url. http handles the request and response streams. url will parse the url including querystring which is required here, we parse out chunkCount from the querystring (and take 1 on default). Parsing the url make its easy to route the url, in the code we are only interested in urls that start with /slowAndChunkyWebService.

Use Visual Studio 2017 community with Node,js installed and create a console app then paste in the following code, then press F5 to start running.

'use strict';

const http = require('http');
const url = require('url');
const port = 34957;

console.log('Slow and chunky web servicen');

const requestHandler = (request, response) => {

    var url_parts = url.parse(request.url, true);
    var query = url_parts.query;

    if (url_parts.pathname == '/slowAndChunkyWebService') {

        var chunkCount = 0;

        try {
            chunkCount = parseInt(query.chunkCount);

            if (typeof (chunkCount) == "undefined") { chunkCount = 1; }

        }
        catch (ex) { chunkCount = 1; }

        console.log('main code about to call myWriteChunk() with chunkCount' + chunkCount + 'n');
        myWriteChunk(response, chunkCount);

    } else {
        console.log(request.url);
        response.end(request.url);
    }
}

function myWriteChunk(response, chunkCount) {
    console.log('myWriteChunk called chunkCount' + chunkCount + 'n')

    var chunky = "foobar".repeat(10);

    response.write(chunky);

    chunkCount--;

    if (chunkCount > 0) {
        setTimeout(function () { myWriteChunk(response, chunkCount) }, 1000)
    } else {
        console.log('about to schedule myResponseEndn')
        setTimeout(function () { myResponseEnd(response) }, 1000)
        
    }
}

function myResponseEnd(response) {
    console.log('myResponseEnd calledn')
    response.end();
}



const server = http.createServer(requestHandler);

server.listen(port, (err) => {
    if (err) {
        return console.log('something bad happened', err);
    }

    console.log(`server is listening on ${port}`);
})

One can test this by going to a browser and typing in the url...

http://localhost:34957/slowAndChunkyWebService?chunkCount=2

In the node.js console the following output should be seen...

Debugger listening on ws://127.0.0.1:48449/40a2b131-8546-4801-adf3-c3b16d0b72a2
For help see https://nodejs.org/en/docs/inspector
Debugger attached.
(node:17292) [DEP0062] DeprecationWarning: `node --inspect --debug-brk` is deprecated. Please use `node --inspect-brk` instead.
Slow and chunky web service

server is listening on 34957
main code about to call myWriteChunk() with chunkCount2

myWriteChunk called chunkCount2

myWriteChunk called chunkCount1

about to schedule myResponseEnd

myResponseEnd called

/favicon.ico

And the browser will show contents only after all of them have been received.

foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar

Thursday, 25 January 2018

VBA - Node.js - Simple Javascript Webservice

So after having established the limits of ScriptControl and cscript.exe I feel the need to find a Javascript interoperability platform for VBA programmers. The ScriptControl can still parse JSON thanks to Douglas Crockford's scripts still being runnable on Ecmascript v.3 but other javascript libraries are already on Ecmascript v.6.

Javascript Web Service

So we need a new solution and Node.js is the answer. Here we give a simple webservice that takes a Javascript document, extracts some information and returns it. First, the javascript file, open Visual Studio 2017 with Node.js workload installed and open new Node.js console project and paste in the code below.

extractTitleAndUrl()

The extraction logic takes place in extractTitleAndUrl() and is expecting a document of a certain format (actually its a Google Sheets API format) and will extract two facts, title and url from each entry in an array. It adds these two facts to a new smaller object and places them in array. The array is stringified before returning. An error handler traps any problem but foes not give much information. You can see some test data for extractTitleAndUrl() commented out.

The Web Server

Everything that is not extractTitleAndUrl() is web server logic. I'll not explain too much of the plumbing here because other documentation does it better. In the requestHandler() we inspect the url to see if it has suffix '/extractTitleAndUrl' and if so run our logic otherwise print a hello world message. The body of the request is accumulated in chunks because Node.js splits these tasks into very small pieces so that code interleaves, this is the asynchronous model. Once the body is fully received then our logic extractTitleAndUrl() can be executed.

VBA client code is given below

'use strict';

const http = require('http');
const port = 80;

console.log('\nversion Juno\n');

const requestHandler = (request, response) => {

    if (request.url == '/extractTitleAndUrl') {
        //https://nodejs.org/en/docs/guides/anatomy-of-an-http-transaction/

        let body = [];
        request.on('data', (chunk) => {
            body.push(chunk);
        }).on('end', () => {
            body = Buffer.concat(body).toString();
            // at this point, `body` has the entire request body stored in it as a string
            console.log('\nbody received:\n\n'+body);

            var titleAndUrl = extractTitleAndUrl(body)
            console.log('\nextracted title and url:\n\n' + titleAndUrl );

            response.end(titleAndUrl);
        });

    } else {
        console.log(request.url);
        response.end('Hello Node.js Server!');
    }
}

const server = http.createServer(requestHandler);

server.listen(port, (err) => {
    if (err) {
        return console.log('something bad happened', err);
    }

    console.log(`server is listening on ${port}`);
})

//var doc = {
//    "feed": {
//        "entry":
//        [{ "title": { "$t": "1 Med" }, "link": [{ "href": "https//removed.1.Med.." }] },
//        { "title": { "$t": "2 Dent" }, "link": [{ "href": "https//removed.2.Dent.." }] },
//        { "title": { "$t": "3 Vet" }, "link": [{ "href": "https//removed.3.Vet.." }] }]
//    }
//};

//console.log(JSON.stringify(extractTitleAndUrl(doc)));

function extractTitleAndUrl(text) {

    try {
        var doc = JSON.parse(text);
        var newArray = new Array();

        for (var i = 0; i < doc.feed.entry.length; i++) {

            var newObj = new Object();
            newObj['title'] = doc.feed.entry[i].title.$t;
            if (doc.feed.entry[i].link.length = 1) {
                newObj['url'] = doc.feed.entry[i].link[0].href;
            } else {
                newObj['url'] = doc.feed.entry[i].link[2].href;
            }

            newArray.push(newObj);
        }
        return  JSON.stringify(newArray);
    }
    catch (ex) {
        return ('#error in extractTitleAndUrl!'); 
    }
}

Run the code with the Visual Studio start button, the following should be outputted

Debugger listening on ws://127.0.0.1:15347/1c2b4b25-fa9e-4690-b6a4-524b506491cb
For help see https://nodejs.org/en/docs/inspector
Debugger attached.
(node:5212) [DEP0062] DeprecationWarning: `node --inspect --debug-brk` is deprecated. Please use `node --inspect-brk` instead.

version Juno

server is listening on 80
...

VBA client code

The VBA code is given below. One point of note is that to stop cacheing it is necessary to use ServerXMLHTTP60 and not XMLHTTP60 re this StackOverflow response. The place to start execution is TestWebService(), press F5 there. This should return with the correct results but the console for Node.js should also output some messages...


...
server is listening on 80

body received:

{ "feed": {"entry": [   {     "title": { "$t": "1 Med" },     "link": [ { "href": "https//removed...." } ]   },  
 {     "title": { "$t": "2 Dent" },     "link": [ { "href": "https//removed...." } ]   },  
 {     "title": { "$t": "3 Vet" },     "link": [  { "href": "https//removed...." }]   }] } }

extracted title and url:

[{"title":"1 Med","url":"https//removed...."},{"title":"2 Dent","url":"https//removed...."},{"title":"3 Vet","url":"https//removed...."}]


Option Explicit

'* Tools->References
'MSScriptControl        Microsoft Script Control 1.0        C:WindowsSysWOW64msscript.ocx
'MSXML2                 Microsoft XML, v6.0                 C:WindowsSysWOW64msxml6.dll

Private Function SC() As ScriptControl
    Static soSC As ScriptControl
    If soSC Is Nothing Then


        Set soSC = New ScriptControl
        soSC.Language = "JScript"

        soSC.AddCode "function deleteValueByKey(obj,keyName) { delete obj[keyName]; } "
        soSC.AddCode "function setValueByKey(obj,keyName, newValue) { obj[keyName]=newValue; } "
        soSC.AddCode "function enumKeysToMsDict(jsonObj,msDict) { for (var i in jsonObj) { msDict.Add(i,0); }  } "
        soSC.AddCode GetJavaScriptLibrary("https://raw.githubusercontent.com/douglascrockford/JSON-js/master/json2.js")
        soSC.AddCode "function JSON_stringify(value, replacer,spacer) { return JSON.stringify(value, replacer,spacer); } "
        soSC.AddCode "function JSON_parse(sJson) { return JSON.parse(sJson); } "

    End If
    Set SC = soSC
End Function

Private Function GetJavaScriptLibrary(ByVal sURL As String) As String

    Dim xHTTPRequest As MSXML2.XMLHTTP60
    Set xHTTPRequest = New MSXML2.XMLHTTP60
    xHTTPRequest.Open "GET", sURL, False
    xHTTPRequest.send
    GetJavaScriptLibrary = xHTTPRequest.responseText

End Function

Function SimpleMasterPage() As String

    SimpleMasterPage = "{ ""feed"": {" & _
    """entry"": [ " & _
    "  { " & _
    "    ""title"": { ""$t"": ""1 Med"" }, " & _
    "    ""link"": [ { ""href"": ""https//removed...."" } ] " & _
    "  }, " & _
    "  { " & _
    "    ""title"": { ""$t"": ""2 Dent"" }, " & _
    "    ""link"": [ { ""href"": ""https//removed...."" } ] " & _
    "  }, " & _
    "  { " & _
    "    ""title"": { ""$t"": ""3 Vet"" }, " & _
    "    ""link"": [  { ""href"": ""https//removed...."" }] " & _
    "  }" & _
    "] } }"

    Dim objGutted2 As Object
    Set objGutted2 = SC.Run("JSON_parse", SimpleMasterPage)

End Function

Sub TestWebService()

    '* Do not use XMLHTTP60 because it caches!
    '* https://stackoverflow.com/questions/5235464/how-to-make-microsoft-xmlhttprequest-honor-cache-control-directive#5386957
    
    Dim vBody As Variant
    vBody = SimpleMasterPage


    Dim oXHR As MSXML2.ServerXMLHTTP60
    Set oXHR = New MSXML2.ServerXMLHTTP60
    oXHR.Open "POST", "http://localhost/extractTitleAndUrl"
    oXHR.setRequestHeader "Cache-Control", "no-cache, no-store"
    oXHR.send vBody
    
    Debug.Print oXHR.responseText
    Debug.Assert oXHR.responseText = "[{""title"":""1 Med"",""url"":""https//removed....""},{""title"":""2 Dent"",""url"":""https//removed....""},{""title"":""3 Vet"",""url"":""https//removed....""}]"

    Stop

End Sub


VBA - XMLHttp Request (XHR) does not parse response as XML

HTML was initially conceived to be like XML in that for every opening tag there is a closing tag and the attributes are enclosed in quotes but in reality it breaks these rules and can rarely be used with an Xml parser. So XML is fussy and HTML is not.

However, take a look at the following code; it uses the XmlHttp request (XHR) object but we should note that it never parses the response as Xml unless you write the code (example code given in separate function). I think this is nice use of XHR. The code goes on to insert the response text as Html into a MSHTML.HTMLDocument and from there can web scrape whatever.

Sub DoNotParseXml()

    Dim oXHR As MSXML2.XMLHTTP60
    Set oXHR = New MSXML2.XMLHTTP60
    
    Dim oHtmlDoc As MSHTML.HTMLDocument
    Set oHtmlDoc = New MSHTML.HTMLDocument
    
    oXHR.Open "GET", "https://coinmarketcap.com/all/views/all/" & "?Random=" & Rnd() * 100, False
    oXHR.setRequestHeader "Content-Type", "text/XML"
    oXHR.send

    If oXHR.Status = "200" Then
        
        '* no parse of 'non well-formed xml' take place
        oHtmlDoc.body.innerHTML = oXHR.responseText
    
        '** do some web scraping with MSHTML.HTMLDocument
    
        '... oHtmlDoc.getElementsByClassName("price")

        
        '* but if we had tried to parse the response text .. it would have errored
        ParseXml oXHR.responseText
    End If
End Sub

Private Function ParseXml(ByVal sText As String) As MSXML2.DOMDocument60
    Dim oDom As MSXML2.DOMDocument60
    Set oDom = New MSXML2.DOMDocument60
    oDom.LoadXML sText
    
    '* it would have errored
    Debug.Assert oDom.parseError = 0

End Function

VBA - Webscraping - jQuery selectors available with MSHTML's querySelector and querySelectorAll

So another SO question about web scraping in VBA. I wrote some code but was not happy with it and so revisited it. It turns out that the jQuery selector syntax can be quite advanced, a bit like XPath for Xml.

Tip #1 When using querySelectorAll() use Early-binding

I have seen some strangeness when using querySelectorAll() when getting the length, the problem goes away if you use early binding type library (Tools->References->Microsoft HTML Object Library)

    Dim oSelectors As MSHTML.IHTMLDOMChildrenCollection
    Set oSelectors = oHtml.querySelectorAll("div.blocoCampos input")
    
    Dim lSelectorResultList As Long
    lSelectorResultList = oSelectors.Length

Note above the selector gets input elements which are children of div elements with the class 'blocoCampos'.

Tip #2 When using querySelectorAll() use item to acquire each element not For Each

I have also seen some strangeness when using querySelectorAll() that errors on the Next line of a For Each loop. So avoid by establishing the length of the result array and then use a standard integer loop and acquire each element with item.

    Dim lSelectorResultLoop As Long
    For lSelectorResultLoop = 0 To lSelectorResultList - 1

        Dim objChild As Object
        Set objChild = oSelectors.Item(lSelectorResultLoop)

Selecting grandchild anchor off second span child of a div with id

Given the following HTML source the questioner wanted to navigate to the anchor links. The anchors do have not id and no class; neither do their parent span elements; but the spans' parent div element have (non-unique) id so we can start the capture there.


<div id="resumopesquisa">

  <div id="itemlistaresultados" style="background-color: #EDEDED">
   <span class="labellinha">Acórdãos de Repetitivos</span>
   <!-- <span>  PIS E ICMS E COFINS E CALCULO E BASE E DE REPETITIVOS.NOTA.
   </span> -->
   
   <span><a href="/SCON/jurisprudencia/toc.jsp?livre=ICMS+BASE+DE+CALCULO+PIS+COFINS&repetitivos=REPETITIVOS&&b=ACOR&thesaurus=JURIDICO&p=true">1
     documento(s) encontrado(s)</a></span>
   
  </div>
  
 
 <div id="itemlistaresultados">
  <span class="labellinha">Acórdãos</span>
  <!-- <span>  PIS E ICMS E COFINS E CALCULO E BASE E DE
  </span> -->
  
  <span><a href="/SCON/jurisprudencia/toc.jsp?livre=icms+base+de+calculo+pis+cofins&&b=ACOR&thesaurus=JURIDICO&p=true">284
    documento(s) encontrado(s)</a></span>
  
 </div>
</div>

So let's build up our jQuery selector expression, first let's get the divs but specifiying their id ( yeah, I know I though ids were unique as well) ...

div#itemlistaresultados

But then we need to get the second child span element of the div, we can do this with jQuery's nth-child selector. We simply add a space between the div expression and the span expression to express the parent child relationship ...

div#itemlistaresultados span:nth-child(2)

Finally we pick out the anchor element with

div#itemlistaresultados span:nth-child(2) a

So we can put this jQuery selector expression into MSHTML's querySelectorAll method (use querySelector for singleton results), here is the VBA

    Set oHtml = ie.Document
    Dim objResultList As MSHTML.IHTMLDOMChildrenCollection
    Set objResultList = oHtml.querySelectorAll("div#itemlistaresultados span:nth-child(2) a")

    Dim lResultCount As Long
    lResultCount = objResultList.Length

    Debug.Print
    Dim lResultLoop As Long
    For lResultLoop = 0 To lResultCount - 1

        Dim anchorLoop As MSHTML.HTMLAnchorElement
        Set anchorLoop = objResultList.Item(lResultLoop)

        Debug.Print achLoop.href

    Next

Tip #3 When not required use late binding to get aggregated interface

So when dealing with an input checkbox then it must be understood that its functionality is defined across a great many number of different interfaces such as MSHTML.HTMLInputElement, MSHTML.IHTMLInputElement and many more. Perhaps the input box is a worst case example because it is a multifaceted definition but for illustration here is what OLEView gives the interfaces implemented by the coclass MSHTML.HTMLInputElement ...

    coclass HTMLInputElement {
        [default] dispinterface DispHTMLInputElement;
        [default, source] dispinterface HTMLInputTextElementEvents;
        [source] dispinterface HTMLInputTextElementEvents2;
        [source] dispinterface HTMLOptionButtonElementEvents;
        [source] dispinterface HTMLButtonElementEvents;
        interface IHTMLElement;
        interface IHTMLElement2;
        interface IHTMLElement3;
        interface IHTMLElement4;
        interface IHTMLUniqueName;
        interface IHTMLDOMNode;
        interface IHTMLDOMNode2;
        interface IHTMLDOMNode3;
        interface IHTMLDatabinding;
        interface IHTMLElement5;
        interface IHTMLElement6;
        interface IElementSelector;
        interface IHTMLDOMConstructor;
        interface IHTMLElement7;
        interface IHTMLControlElement;
        interface IHTMLInputElement;
        interface IHTMLInputElement2;
        interface IHTMLInputTextElement;
        interface IHTMLInputTextElement2;
        interface IHTMLInputHiddenElement;
        interface IHTMLInputButtonElement;
        interface IHTMLInputFileElement;
        interface IHTMLOptionButtonElement;
        interface IHTMLInputImage;
        interface IHTMLInputElement3;
        interface IHTMLInputRangeElement;
    };

So instead of figuring out on which interface of the list above a method is implemented it is better to declare the variable with As Object to use late binding, and then all the methods from all of the interfaces are aggregated onto a IDispatch interface.

Links