Part and Parse()-l

Take the Tag property and OpenArgs argument to the next level. This function makes it easy to assign and extract multiple values from a single string.

Part and Parse()-l

Today, I'm excited to write about one of my favorite functions and a key building block to some of the most advanced features I've implemented in Microsoft Access.  The function is named Parse(), and it extracts values from a key-value string.

Parsing connection strings

What do I mean by a "key-value string"?  The easiest way to explain it is with an example.  Here is a typical connect string for a DSN-less connection to a table in SQL Server:

Const CnString As String = "ODBC;DRIVER=SQL Server;SERVER=MyDbServer;" & _ 
    "Trusted_Connection=Yes;APP=Microsoft Office;DATABASE=MyDatabase;"

I first wrote this function to solve the simple problem of extracting values from SQL Server connection strings.  Let's say I wanted to extract the value of the Server key.  This is subtly difficult, as there are three separate instances of the text "Server" within the above string.  

Here's how I would use my Parse() function to extract the Server value:

Debug.Print Parse(CnString, "Server")
MyDbServer

When you call the function, you pass it an assignment character and delimiter.  By default, these are = and ;, respectively.  

A third optional argument is the return type.  The function itself always returns a Variant, but if you request the variant be of a specific type, then the function will coerce the result into that type.  This serves three purposes: 1) avoid Nulls, 2) avoid "Data type mismatch" errors at runtime, and 3) convey semantic meaning about what sort of value you expect to be stored along with that key in the source string.

The Tag property

The Tag property is a string property attached to every form, report, and control in Microsoft Access.  It is the Swiss Army knife of your Access tool box.  You can save any string you want in that property and use it for...anything.  

I like to use it for things like hiding or locking a group of controls at runtime.  At design time, you set the Tag property for a subset of controls. Then, on the Form_Current() event, you loop through each control in the form/report/section, and change some other property of the control based on what is (or is not) saved in the Tag property.

As you start using the Tag property and realize how wonderful it is, eventually you will reach a point where you want to attach two different pieces of data to a single control.  Unfortunately, you can't add extra Tag properties; you just get the one.  The solution? Concatenate as many properties as you need into a single string and assign that to the Tag property.  Then, extract the individual values from the string using the Parse() function.

For example, I wrote a resizing class module that allows for fine-grained control over resizing of form controls.  Imagine a form with four subforms arranged in a 2x2 grid pattern.  We want the subforms to grow evenly in the horizontal direction, but vertically we want the lower subforms to grow three times faster than the upper subforms.  I would set the Tag property for the lower right subform to this:

HSlide=.5;HGrow=.5;VSlide=.25;VGrow=.75

In my resizing class module, I parse each control when the form is opened to determine how each control will need to be moved, grown, or shrunk in relation to the resizing of the form itself.  Here's what that looks like:

CtlProps(htGrow) = Parse(Ctl.Tag, "HGrow")
CtlProps(htSlide) = Parse(Ctl.Tag, "HSlide")
CtlProps(vtGrow) = Parse(Ctl.Tag, "VGrow")
CtlProps(vtSlide) = Parse(Ctl.Tag, "VSlide")

The OpenArgs argument

The DoCmd.OpenForm and DoCmd.OpenReport routines each have an optional OpenArgs argument.  That argument serves the same purpose as the Tag property; it exists as a catch-all for whatever you might want or need.  

Let's say we have a report that the user has filtered based on some criteria.  We're building the WHERE clause using SQL syntax, but we also want to display a human-readable version of that WHERE clause in the page header of the report.  Here's how we might call that report, using another of my favorite string functions, Conc():

Dim RptInfo As String

RptInfo = Conc(RptInfo, "DisplayFilter=Accounts Added This Year\nRegion = South", ";")
RptInfo = Conc(RptInfo, "ShadeAlternateRows=" & Me.chkShadeRows, ";")
RptInfo = Conc(RptInfo, "FormatForDuplex=" & Me.chkDuplex, ";")

Docmd.OpenReport "MyReport", acViewPreview, , Criteria, , RptInfo

Then, in the Report_Open() event, we could extract this information back out using the Parse() function:

Dim DisplayFilter As String
DisplayFilter = Parse(Me.OpenArgs, "DisplayFilter", vbString)

Dim ShadeRows As Boolean
ShadeRows = Parse(Me.OpenArgs, "ShadeAlternateRows", vbBoolean)

Dim FormatForDuplex As Boolean
FormatForDuplex = Parse(Me.OpenArgs, "FormatForDuplex", vbBoolean)

The alternative to using OpenArgs for passing this sort of information to a report (or form), is often to use some sort of global state.  That is, a global variable, the TempVars collection, or even the form where you collected this information itself.  However, once you start relying on global state, it becomes way too easy to introduce subtle and hard-to-find bugs into your program.  Anything we can do to avoid that is a good thing.

Disadvantage

One big disadvantage to this approach is that it lacks compile-time checking.  If I misspell one of the strings or use slightly different key names for setting the values versus retrieving them, then I'll end up with a bug that could easily go missed. The best way to avoid that is by using constants for the key strings.  Of course, those constants would need to be in the global namespace, which is not ideal either.

Regardless of how you decide to mitigate the issue, it is important that you at least be aware of it.

The code: Parse()

'---------------------------------------------------------------------------------------
' Procedure : Parse
' DateTime  : 7/16/2009 - 4/30/2015 17:41
' Author    : Mike Wolfe <mike@nolongerset.com>
' Purpose   : Parse a string of keys and values (such as a connection string) and return
'               the value of a specific key.
' Usage     - Use to pass multiple arguments to forms via OpenArgs
'           - Keep multiple arguments in the Tag property of forms and controls.
'           - Use to parse a user-entered search string.
' Notes     - Defaults to using connection string formatted key-value pairs.
'           - Specifying a ReturnType guarantees the type of the result and allows the
'               function to be safely called in certain situations.
'  7/23/09  : Modified to allow the use of a literal space as a delimiter while allowing
'               values to have spaces as well. For example:
'>>> Parse("Name : Abraham Lincoln Address : 1600 Pennsylvania Ave NW Zip : 20500","Address",12,":"," ")
' 1600 Pennsylvania Ave NW
'  7/23/09  : Passing an empty key returns the start of the string until the first
'               key is found.  For example:
'>>> Parse("Abraham Lincoln Address : 1600 Pennsylvania Ave NW Zip : 20500","",12,":"," ")
' Abraham Lincoln
'>>> Parse("BlueReqd=True; RedReqd=True; Reqd=False; GreenReqd=True", "Reqd", 11)
' False
'  4/30/15  : Allow for assignment characters to be used within a value; for example:
'>>> Parse("Caption=Test;IsIn=SELECT ID FROM MyTable WHERE FKey=1;Foo=Bar", "IsIn")
' SELECT ID FROM MyTable WHERE FKey=1
'---------------------------------------------------------------------------------------
'
Function Parse(Txt As Variant, Key As String, _
               Optional ReturnType As VbVarType = vbVariant, _
               Optional AssignChar As String = "=", _
               Optional Delimiter As String = ";") As Variant
    Dim EndPos As Integer, Result As Variant
    Result = Null
    If IsNull(Txt) Then
        Parse = Null
    ElseIf Len(Key) = 0 Then
        EndPos = InStr(Txt, AssignChar)
        If EndPos = 0 Then
            Result = Trim(Txt)
        Else
            If InStrRev(Txt, " ", EndPos) = EndPos - 1 Then
                EndPos = InStrRev(Txt, Delimiter, EndPos - 2)
            Else
                EndPos = InStrRev(Txt, Delimiter, EndPos)
            End If
            Result = Trim(Left(Txt, EndPos))
        End If
    Else
        Dim KeyStartPos As Integer, ValStartPos As Integer
        KeyStartPos = InStr(Txt, Key & AssignChar)
        'Allow for space between Key and Assignment Character
        If KeyStartPos = 0 Then
            KeyStartPos = InStr(Txt, Key & " " & AssignChar)
            If KeyStartPos > 0 Then ValStartPos = KeyStartPos + Len(Key & " " & AssignChar)
        Else
            ValStartPos = KeyStartPos + Len(Key & AssignChar)
        End If
        If ValStartPos = 0 Then
            Parse = Null
        Else
            'Check prior characters to ensure we are not looking at a substring of another key
            Dim i As Long
            For i = KeyStartPos - 1 To 1 Step -1
                Dim ThisChar As String
                ThisChar = Mid(Txt, i, 1)
                Select Case ThisChar
                Case Delimiter
                    'we're at the delimiter, no need for further checking
                    Exit For
                    'note that the order of Case statements is important; we need to check
                    '   for the delimiter first in case space or tab are being used as delimiters
                Case " ", vbTab
                    'ignore whitespace
                Case Else
                    'it appears this is a substring of another key, so we'll make a recursive
                    '   call to this function starting with the character beyond the StartPos:
                    Parse = Parse(Mid(Txt, KeyStartPos + 1), Key, ReturnType, AssignChar, Delimiter)
                    Exit Function
                End Select
            Next i
            
            'Allow for assignment characters to be used within a value
            Dim NextDelimPos As Long
            NextDelimPos = InStr(ValStartPos, Txt, Delimiter)
            Dim BeginEndPosSearch As Long
            BeginEndPosSearch = ValStartPos
            Do
                EndPos = InStr(BeginEndPosSearch, Txt, AssignChar)
                BeginEndPosSearch = EndPos + 1
            Loop Until EndPos > NextDelimPos Or EndPos = 0
            '----------------------------------------------------------
            
            If EndPos = 0 Then
                If Right(Txt, Len(Delimiter)) = Delimiter Then
                    Result = Trim(Mid(Txt, ValStartPos, _
                                      Len(Txt) - Len(Delimiter) - ValStartPos + 1))
                Else
                    Result = Trim(Mid(Txt, ValStartPos))
                End If
            Else
                If InStrRev(Txt, Delimiter, EndPos) = EndPos - 1 Then
                    EndPos = InStrRev(Txt, Delimiter, EndPos - 2)
                Else
                    EndPos = InStrRev(Txt, Delimiter, EndPos)
                End If
                If EndPos < ValStartPos Then
                    Result = Trim(Mid(Txt, ValStartPos))
                Else
                    Result = Trim(Mid(Txt, ValStartPos, EndPos - ValStartPos))
                End If
            End If

        End If
    End If
    Select Case ReturnType
    Case vbBoolean
        If IsNull(Result) Or Len(Result) = 0 Or Result = "False" Then
            Parse = False
        Else
            Parse = True
            If IsNumeric(Result) Then
                If Val(Result) = 0 Then Parse = False
            End If
        End If

    Case vbCurrency, vbDecimal, vbDouble, vbInteger, vbLong, vbSingle
        If IsNumeric(Result) Then
            Select Case ReturnType
            Case vbCurrency: Parse = CCur(Result)
            Case vbDecimal: Parse = CDec(Result)
            Case vbDouble: Parse = CDbl(Result)
            Case vbInteger: Parse = CInt(Result)
            Case vbLong: Parse = CLng(Result)
            Case vbSingle: Parse = CSng(Result)
            End Select
        Else
            Select Case ReturnType
            Case vbCurrency: Parse = CCur(0)
            Case vbDecimal: Parse = CDec(0)
            Case vbDouble: Parse = CDbl(0)
            Case vbInteger: Parse = CInt(0)
            Case vbLong: Parse = CLng(0)
            Case vbSingle: Parse = CSng(0)
            End Select
        End If

    Case vbDate
        If IsDate(Result) Then
            Parse = CDate(Result)
        ElseIf IsNull(Result) Then
            Parse = 0
        ElseIf IsDate(Replace(Result, "#", "")) Then
            Parse = CDate(Replace(Result, "#", ""))
        Else
            Parse = 0
        End If

    Case vbString
        Parse = Nz(Result, vbNullString)

    Case Else
        If IsNull(Txt) Then
            Parse = Null
        ElseIf Result = "True" Then
            Parse = True
        ElseIf Result = "False" Then
            Parse = False
        ElseIf IsNumeric(Result) Then
            Parse = Val(Result)
        Else
            Parse = Result
        End If
    End Select
End Function

Image by MichaelGaida from Pixabay

All original code samples by Mike Wolfe are licensed under CC BY 4.0