TableAdapters and Transactions

In my last post I showed you how to save related parent-child DataTables in proper update, insert and delete order by controlling how the TableAdapters saved the rows. In this next post I'll demonstrate how we can do this inside a database transaction. We use transactions when we want all rows updated consistently. Meaning if one row fails to update in the database, we want the whole batch to roll back like nothing happened. This can be useful in many scenarios.

Say you are saving an order and it's details into two tables, but you want to make sure that if any of the detail rows fail to save to the database, that the entire order is not saved -- keeping the order and its details together at all times. In the previous example if one of the detail rows failed to update, an exception would be thrown but part of the order would be saved into the database. It would be up to you to retry the save again on the rest of the rows on the client. This could cause potential inconsistencies in our data.

So what we want to do is to begin a transaction, submit all the rows for save, then commit the transaction if no exceptions were thrown, otherwise we want to roll back the entire batch like nothing happened. It's important to note that transactions are a costly database operation and you should only use them if you need to use them. For instance, don't use them if you are only updating one row at a time. It's best to use them when you are editing related rows on a single entity that needs to save consistently. For instance, if you are editing one single order and its details this is a good set of rows that should probably participate in a database transaction.

When we want our TableAdapters to work inside a transaction there are a few things we need to do in addition to setting up the DataRelation properly like I showed in the previous post. First we need to make sure that the DataAdapter behind our TableAdapter is set up properly. DataAdapters are the objects that bridge between DataSets and the database, executing the commands we define. Remember that a TableAdapter is essentially a nice wrapper around all the select, insert, update and delete ADO.NET command objects. We need to make sure that the DataAdapter exposed by the Adapter property on the TableAdapter is set up to not accept changes on the DataRows during an update. This is because if the transaction fails, we need all the RowStates preserved because we're going to roll back the database transaction. So set Adapter.AcceptChangesDuringUpdate = False. The other property we want to set on the adapter will tell it to stop processing the rows if an error occurs, Adapter.ContinueUpdateOnError = False.

Next we need to make sure that all the TableAdapters involved in the transaction use the same database connection. By default, TableAdapters open and close their own connections so we will need to write some code to be able to manually set the TableAdapter's connection object. The TableAdapter exposes a Connection property that we can set. What actually happens when we set this property is that all command objects are set with this connection.

Finally we need to set up the transaction object itself. There are a couple ways to set up database transactions, you can use the ADO.NET SqlTransaction object or you can use the .NET 2.0 TransactionScope object in the System.Transactions namespace. Let's take a look at both of these techniques using an application I built over the Northwind database.

Using the ADO.NET SqlTransaction object requires you to also set the transaction object manually onto the commands.  The command objects must have a reference to the same connection as well as the same transaction object. When you use a SqlTransaction, you have to set this up manually. Luckily you can easily extend your TableAdapter classes using Partial Classes. Here I created a method on each of my TableAdapters called AssignConnection that allows us to pass in a SqlConnection and a SqlTransaction object and sets them up:

Namespace OrdersDataSetTableAdapters

    Partial Class OrdersTableAdapter

        Public Sub AssignConnection(ByVal conn As SqlClient.SqlConnection, _

Optional ByVal trans As SqlClient.SqlTransaction = Nothing)

            Me.Connection = conn

            If trans IsNot Nothing Then

                Me.Adapter.InsertCommand.Transaction = trans

                Me.Adapter.DeleteCommand.Transaction = trans

                Me.Adapter.UpdateCommand.Transaction = trans

            End If

            Me.Adapter.AcceptChangesDuringUpdate = False

            Me.Adapter.ContinueUpdateOnError = False

        End Sub

    End Class

    Partial Class Order_DetailsTableAdapter

        Public Sub AssignConnection(ByVal conn As SqlClient.SqlConnection, _

Optional ByVal trans As SqlClient.SqlTransaction = Nothing)

            Me.Connection = conn

            If trans IsNot Nothing Then

                Me.Adapter.InsertCommand.Transaction = trans

                Me.Adapter.DeleteCommand.Transaction = trans

                Me.Adapter.UpdateCommand.Transaction = trans

            End If

            Me.Adapter.AcceptChangesDuringUpdate = False

            Me.Adapter.ContinueUpdateOnError = False

        End Sub

    End Class

End Namespace

Now that we have this set up we need to create the SqlConnection, begin the transaction and assign it to all the TableAdapters, then we can start our ordered save. However, what we're going to save is not the OrdersDataset that is directly on our form, we need to create a copy of just the changes and try to save that. Then if all goes well we will merge those changes with those on our main OrdersDataset. This technique avoids any concurrency errors that may occur if your transaction failed and you tried to resubmit the save. To make this work smoothly with inserted rows, it is recommended that we set the AutoIncrementSeed and AutoIncrementStep on the OrderID primary key in the OrdersDataTable to -1, -1. (You would do this on any AutoIncrementing primary key that was involved in the transaction.)

This allows us to remove the added rows on the client dataset easily before we merge the saved rows. This is needed because the DataSet.Merge() method looks at the primary keys of rows to determine row identity. If we left the inserted rows in our OrdersDataSet and tried to merge the saved rows we may end up with duplicates because the inserted database key may not match the client DataSet.

So let's write some code! First, get the changes and then create a new SqlConnection, open it, and then call BeginTransaction. That returns a SqlTransaction which we use to assign to our TableAdapters as you saw in the partial class code above.

Dim changes As DataSet = Me.OrdersDataSet.GetChanges()

If changes IsNot Nothing Then

  Dim conn As New SqlClient.SqlConnection(My.Settings.NorthwindConnectionString)

  Dim trans As SqlClient.SqlTransaction = Nothing

  Try

  conn.Open()

  trans = conn.BeginTransaction

 

  Me.OrdersTableAdapter.AssignConnection(conn, trans)

  Me.Order_DetailsTableAdapter.AssignConnection(conn, trans)

Next we process the ordered save, similar to what we did in the previous post, but this time we're working with a copy of the rows from the changes DataSet.

'Send Adds/Updates in Parent-Child order

Dim orderUpdates() As DataRow = _

changes.Tables("Orders").Select("", "", _

DataViewRowState.Added Or DataViewRowState.ModifiedCurrent)

Dim detailUpdates() As DataRow = _

changes.Tables("Order Details").Select("", "", _

DataViewRowState.Added Or DataViewRowState.ModifiedCurrent)

Me.OrdersTableAdapter.Update(orderUpdates)

Me.Order_DetailsTableAdapter.Update(detailUpdates)

'Send Deletes in Child-Parent order

Dim orderDeletes() As DataRow = _

changes.Tables("Orders").Select("", "", _

DataViewRowState.Deleted)

Dim detailDeletes() As DataRow = _

changes.Tables("Order Details").Select("", "", _

DataViewRowState.Deleted)

Me.Order_DetailsTableAdapter.Update(detailDeletes)

Me.OrdersTableAdapter.Update(orderDeletes)

Next we commit the transaction and merge the changes or roll the transaction back if there was an exception thrown:

      trans.Commit()

 

      'Merges the changes and accepts them when the transaction succeeds.

      Me.MergeAfterSave(changes)

  Catch ex As Exception

      MsgBox(ex.ToString)

      'Rollback the transaction if there is an error

      If trans IsNot Nothing Then trans.Rollback()

      Finally

      conn.Close()

      End Try

  End If

Merging changes is very simple, you just remove all the rows with -1 keys, call Merge, then AcceptChanges:

Private Sub MergeAfterSave(ByVal dsChanges As DataSet)

    Dim row As DataRow

    Dim tblTarget As DataTable

    Dim i As Integer

    With Me.OrdersDataSet

        'First remove all the new rows that were added on the client.

        For i = 0 To .Tables.Count - 1

            tblTarget = .Tables(i)

            For Each row In tblTarget.Select("", "", DataViewRowState.Added)

                tblTarget.Rows.Remove(row)

            Next

        Next

        'Now merge and accept the changes

        .Merge(dsChanges)

        .AcceptChanges()

    End With

End Sub

So that's the technique using SqlTransaction. Next I want to show you how our code gets a bit simpler if we use the TransactionScope object. This class is new to .NET 2.0 and it allows all kinds of flexibility when working with transactions and not just database transactions. Use this class if you need distributed transaction support like if you are saving to multiple database or other DTC enabled resources. You can read more about the TransactionScope object here and here. Keep in mind that the TransactionScope object will automatically promote a database transaction to a distributed transaction if you have different connection references and its a bit tricky to get it right, so you may opt for the "more code but safer" implementation above. Distributed transactions are very expensive compared to database transactions so use them wisely!

When we use TransactionScope we don't have to manually set the transaction object on our TableAdapter commands anymore, we just need to set up the shared connection. Also, we don't have to call Rollback explicitly -- this is done automatically if an exception is thrown. Here's the syntax of the entire save method using this technique:

Private Function SaveInTransactionScopeTransaction() As Boolean

    Dim saved As Boolean = False

    Dim changes As DataSet = Me.OrdersDataSet.GetChanges()

    If changes IsNot Nothing Then

        Dim conn As New SqlClient.SqlConnection(My.Settings.NorthwindConnectionString)

        Me.OrdersTableAdapter.AssignConnection(conn)

        Me.Order_DetailsTableAdapter.AssignConnection(conn)

        Try

            Using ts As New System.Transactions.TransactionScope

                conn.Open()

                'Send Adds/Updates in Parent-Child order

                Dim orderUpdates() As DataRow = _

changes.Tables("Orders").Select("", "", _

DataViewRowState.Added Or DataViewRowState.ModifiedCurrent)

                Dim detailUpdates() As DataRow = _

changes.Tables("Order Details").Select("", "", _

DataViewRowState.Added Or DataViewRowState.ModifiedCurrent)

                Me.OrdersTableAdapter.Update(orderUpdates)

                Me.Order_DetailsTableAdapter.Update(detailUpdates)

                'Send Deletes in Child-Parent order

                Dim orderDeletes() As DataRow = _

changes.Tables("Orders").Select("", "", _

DataViewRowState.Deleted)

                Dim detailDeletes() As DataRow = _

changes.Tables("Order Details").Select("", "", _

DataViewRowState.Deleted)

                Me.Order_DetailsTableAdapter.Update(detailDeletes)

                Me.OrdersTableAdapter.Update(orderDeletes)

                'The TransactionScope object will automatically perform a

                ' rollback if an exception is raised.

                ts.Complete()

            End Using

            saved = True

            'Merge the changes and accept them when the transaction succeeds.

            Me.MergeAfterSave(changes)

        Catch ex As Exception

            MsgBox(ex.ToString)

        Finally

            conn.Close()

        End Try

    End If

    Return saved

End Function

As you can see this is a bit cleaner code, but as I said, be careful using it. You can open up SQL-Profiler and have a look at the transactions that are being sent (you may have to add those events to your trace template). I've attached a complete sample application that shows these various techniques on different sets of Order data. As always, use this as a guide in your applications but make sure your scenarios call for transactions in the first place before diving in here. The sample application shows various sets of data from all orders, to just one customer's orders, to just a single order. This last scenario is where transactions fit the best. You can control if transactions should be used in each of these scenarios and which technique by selecting the appropriate value in the dropdown in the toolstrip at the top of the order form.

May all your transactions be short and succeed!

Visual Studio 2008 Update: Use the new TableAdapterManager to update related tables in a transaction. See this post for details .

NorthwindTransaction.zip