How to use DataMacros to run VBA to log record changes

Viewed 339

I have an MS Access database with multiple tables, and each table has different fields (each table has a primary key). Whenever someone changes an existing record, I want the before/after status to be logged into a 'history' table, but I'm trying to avoid making a DataMacro for 20+ fields per table, since that seems cumbersome and difficult to manage.

To accomplish this, I've added "Before Change" and "After Update" macros that each have one call of SetLocalVar to run public vba functions that reside in a local module. The "before" function cycles through whatever fields exist in the current table to build a dictionary of pre-change values. The "after" function repeats the process to identify the field that changed, so that information can be added to a history table (contains table name, field name, before/after values, user, time stamp, etc.).

The problem I'm having is that the "After Update" function only reveals the pre-change data. I can't tell what the new value is or which field changed. I can't feed [Old].[FieldName],[FieldName] as inputs to SetLocalVar because I don't know beforehand which fields will be updated (and field names differ between tables). And I can't call Requery or Refresh because it collides with the "After Update" process.

Of all the similar questions asked here and elsewhere that I can find, they either start with explicitly knowing which field will change, or else not providing that level of detail in their log events. Or maybe my google-fu is weak.

Here is what I have:

Before SetLocalVar expression (different input table name per table):

=SetupLogEvent("Table01",[KeyName])

After SetLocalVar expression:

=SubmitLogEvent("Table01",[KeyName])

Module Code

Public BeforeFields As Scripting.Dictionary

Public Function SetupLogEvent(ByVal TableName As String, ByVal KeyName As String)
    Set BeforeFields = New Scripting.Dictionary
    Set rs = CurrentDb.OpenRecordset("SELECT * FROM [" & TableName & "] WHERE KeyName='" & KeyName & "'")
    Dim i As Long
    With rs
        .MoveLast
        .MoveFirst
        For i = 0 To rs.Fields.Count - 1
            fName = rs.Fields(i).Name
            fVal = rs.Fields(i).Value
            BeforeFields.Add fName, fVal
        Next i
    End With
    rs.Close
    SetupLogEvent = True
End Function

Public Function SubmitLogEvent(ByVal TableName As String, ByVal KeyName As String)
    DoEvents
    Dim MakeUpdate As Boolean
    MakeUpdate = False
    Set rs = CurrentDb.OpenRecordset("SELECT * FROM [" & TableName & "] WHERE KeyName='" & KeyName & "'")
    With rs
        .MoveLast
        .MoveFirst
        For i = 0 To rs.Fields.Count - 1
            fName = rs.Fields(i).Name
            fVal = rs.Fields(i).Value
            If fVal <> BeforeFields(fName) Then
                Debug.Print ("Modified field is " & fName)
                MakeUpdate = True
                Exit For
            End If
        Next i
    End With
    rs.Close
    If MakeUpdate = True Then
        'Run SQL code to update the history table with the relevant information
        'DoCmd.RunSQL <SQL code here>
    End If
    Set BeforeFields = Nothing
    SubmitLogEvent = True
End Function

I suspect I should be using some method other than cycling through the recordset, but I'm not sure what I should be doing instead. I suppose I could add a couple dozen optional inputs to the after function and manually feed in each [FieldName] of each table into each table's SetLocalVar expression, but I can't imagine the system being so inflexible as to require that. There's got to be a better way, right?

Edit: I've updated the title of this question and my reason for avoiding writing a DataMacro for each field for future readers of this topic.

1 Answers

Thanks to braX, krish KM, and SunKnight for your comments above.

I've developed a process that fits my own needs, but I'll briefly explain my reasoning:

Limitations of other methods:

  1. Allenbrowne's solution (1) requires the user to use a form and (2) requires the keys to be AutoNumbers (mine have to be string text)
  2. Scottgem's solution precludes the use of forms. I offer a form to my users to quickly update fields they commonly interact with, but some tables have 20+ fields and any form I'd give them (12+ tables with 20+ semi-unique fields) would be more complicated than just letting them interact with the tables themselves (while generally frowned upon, table exposure happens to be a non-issue in this case, as long as we have some tracking).
  3. Manually generated DataMacros are inflexible - I can't copy-paste any part of them to a new table to quickly update the fields I'm interested in tracking (add, remove, change, etc.). This database is new, so I expect priorities and focus to change as new fields are added over time.

Solution:

At the end of my posted question, I mentioned I could feed the updated values into a function with optional inputs, and have the function do the before/after comparison internally, so that's what I've done.

Limitations:

  1. DataMacros throw errors if you don't handle null values in the expression

  2. DataMacros have a 255 character limit

Due to #1 above, I had to wrap the inputs in Nz() functions. My scheme also involves feeding inputs to the "after" function in key/value format, which requires a verbose declaration. With the 255 character limit, I decided to only track 6-10 fields per table at a time (to do more than this, I just repeat the process in a second DataMacro).

Public TableChangedName As String
Public TableChangedKey As String
Public SuppressLog As Boolean

Public Function SetupLogEvent(ByVal TableName As String, ByVal KeyName As String)
    'This function reads the recordset as-is to build a 'before' dictionary

    'SuppressLog is True when updating via user form
    'The form will make its own submissions to the history log to specify which form did the update
    If SuppressLog = False Then
        Set BeforeFields = New Scripting.Dictionary
        Set rs = CurrentDb.OpenRecordset("SELECT * FROM [" & TableName & "] WHERE KeyName='" & KeyName & "'")
        Dim i As Long
        Do While Not rs.EOF
            rs.MoveLast
            rs.MoveFirst
            For i = 0 To rs.Fields.Count - 1
                fName = rs.Fields(i).Name
                fVal = rs.Fields(i).Value
                BeforeFields.Add fName, CStr(Nz(fVal, ""))
            Next i
            rs.MoveNext
        Loop
        rs.Close
        TableChangedName = TableName
        TableChangedKey = KeyName
        SetupLogEvent = True
    End If
End Function

Public Function SubmitLogEvent(Optional ByVal Input1 As String = "None", Optional ByVal Input2 As String = "None", Optional ByVal Input3 As String = "None", Optional ByVal Input4 As String = "None", Optional ByVal Input5 As String = "None", Optional ByVal Input6 As String = "None", Optional ByVal Input7 As String = "None", Optional ByVal Input8 As String = "None", Optional ByVal Input9 As String = "None", Optional ByVal Input10 As String = "None", Optional ByVal Input11 As String = "None", Optional ByVal Input12 As String = "None")
    'This function submits an entry to the _History table for each field that was changed, marking the change as "Manually updated" (i.e. no form used)

    'SuppressLog is True when updating via user form
    'The form will make its own submissions to the history log to specify which form did the update
    If SuppressLog = False Then
        Dim InputArray As Variant
        InputArray = Array(Input1, Input2, Input3, Input4, Input5, Input6, Input7, Input8, Input9, Input10, Input11, Input12)
        Set AfterFields = New Scripting.Dictionary
        For i = LBound(InputArray) To UBound(InputArray) Step 2
            If InputArray(i) = "None" Then
                'End of used input fields
                Exit For
            Else
                AfterFields.Add InputArray(i), InputArray(i + 1)
            End If
        Next i
        DoCmd.SetWarnings False
        For Each fName In AfterFields.Keys
            If BeforeFields(fName) <> AfterFields(fName) Then
                strSQL = "INSERT INTO _History ([TableModified],[KeyName],[Field],[From],[To],[User],[TimeStamp],[Method]) VALUES ('" & TableChangedName & "','" & TableChangedKey & "','" & fName & "','" & BeforeFields(fName) & "','" & AfterFields(fName) & "','" & Environ("username") & "','" & Now() & "','Manually updated')"
                Debug.Print ("strSQL = """ & strSQL & """")
                DoCmd.RunSQL strSQL
            End If
        Next fName
        DoCmd.SetWarnings True
    End If
    SubmitLogEvent = True
End Function

With these two functions above, the only thing I have to update per table are the expressions.

Before SetLocalVar expression:

=SetupLogEvent("Table01",[KeyName])

After SetLocalVar expression:

=SubmitLogEvent('Field1',Nz([Field1]),'Field2',Nz([Field2]),'Field3',Nz([Field3]),'Field4',Nz([Field4]))


If a new field gets added to one or more tables that needs to be tracked, I update the FieldList variable and re-run the function below:

Private Function PrintExpression()
    'Enter the list of fields you want to track, separated by commas
    FieldList = "Field_1,Field_2,Field_3,Field_4,Field_5"

    exprString = "=SubmitLogEvent("
    For Each Entry In Split(FieldList, ",")
        exprString = exprString & "'" & Entry & "',Nz([" & Entry & "]),"
    Next
    exprString = RxReplace(exprString, ",$", ")")
    If Len(exprString) > 255 Then
        Call MsgBox("This result is > 255 characters (" & Len(exprString) & ") and will be rejected.", vbExclamation + vbOKOnly, "Input Too Long")
    Else
        Debug.Print ("=SetupLogEvent(""<<< Table Name >>>"",[KeyName])")
        Debug.Print (exprString)
    End If
End Function

Output:

=SetupLogEvent("<<< Table Name >>>",[KeyName])

=SubmitLogEvent('Field_1',Nz([Field_1]),'Field_2',Nz([Field_2]),'Field_3',Nz([Field_3]),'Field_4',Nz([Field_4]),'Field_5',Nz([Field_5]))

I copy/paste the 2nd expression into any of the tables that use those fields, and I'm done. If I need to track more than 6-10 fields per table, I re-run the function above with the 2nd FieldList, and add it under a second DataMacro for the table. Rinse and repeat as needed.

Related