Export (0) Print
Expand All
Around the World with Visual Basic
Asynchronous Method Execution Using Delegates
Building a Progress Bar that Doesn't Progress
Calling All Operators
Create a Graphical Editor Using RichTextBox and GDI+
Creating A Breadcrumb Control
Creating a Five-Star Rating Control
Creating and Managing Secondary Threads
Data Binding Radio Buttons to a List
Deploying Assemblies
Designing With Custom Attributes
Digital Grandma
Doing Async the Easy Way
Extracting Data from .NET Assemblies
Implementing Callbacks with a Multicast Delegate
Naming and Building Assemblies in Visual Basic .NET
Programming Events of the Framework Class Libraries
Programming I/O with Streams in Visual Basic .NET
Reflection in Visual Basic .NET
Remembering User Information in Visual Basic .NET
Advanced Basics: Revisiting Operator Overloading
Scaling Up: The Very Busy Background Compiler
Synchronizing Multiple Windows Forms
Thread Synchronization
Updating the UI from a Secondary Thread
Using Inheritance in the .NET World
Using the ReaderWriterLock Class
Visual Basic: Simplify Common Tasks by Customizing the My Namespace
What's My IP Address?
Windows Forms Controls: Z-order and Copying Collections
Expand Minimize

Resolving DataSet Conflicts

Visual Studio .NET 2003
 

Brian A. Randell
MCW Technologies, LLC

Summary: Discusses working with data in a disconnected state and provides one possible solution for correcting DataSet conflicts. The solution included with the sample code is composed of Visual Basic .NET, ADO.NET, and the .NET Framework. (13 printed pages)

Download the DataSetConflicts.msi sample file.

Note   To run the sample application, you will need a version of Microsoft Windows® with version 1.0 SP2 of the .NET Framework installed. All code presented in this article is Visual Basic® .NET, written and tested using Visual Studio® 2002. In addition you need access to an instance of SQL Server 2000 SP3 on a machine with at least 3 megabytes of free disk space for the sample database. Testing was done using a local instance of SQL Server 2000 Personal Edition, SP3 on Windows XP Professional SP1.

Introduction
Using the Application
Application Design Issues
How It Works
Conclusion

Introduction

The new ADO.NET DataSet object, introduced in the first release of the .NET Framework, provides a large array of options for working with data in a disconnected state. It supports being bound to user interface (UI) objects, such as the data grid. It supports marshaling by value across the network wire. It even supports working with the data offline until changes can be sent back up to the data source. It is this specific offline usage that can lead to problems.

By disconnecting the data from a live database connection, you can increase application scalability and flexibility. For example, you can connect to your corporate network using dial-up, download some data into a dataset, and then disconnect and keep working. Later, you reconnect and update the database with your changes. But what happens if another user makes changes to the same record you're working on? More importantly, what should happen?

This sample application provides one possible solution to this problem. As with any complex problem, there is no single answer that fits every application. In fact, this example is arbitrarily focused on one particular type of solution. As you will see, there are many decisions you'll need to make to find out the best course of action for your particular situation. This sample should provide a starting point to help you create the right solution for your application.

Using the Application

To get started, run the compiled application, DataSetConflicts.exe, or open the Visual Studio® .NET solution DataSetConflicts.sln. When you first start the application, it should look something like Figure 1 below. There is only one real option when you first get started and that is to build the sample database needed to run the application. Although building the application on top of one of the existing SQL Server sample databases, like PUBS, might have been easier, many developers don't feel comfortable messing with databases that they didn't create. Therefore, this sample uses a simple one-table database named DataConSample. Building the database will require a SQL Server account with the necessary permissions to access the master database, permissions to create the sample database, and have access to the sample table once installed.

click for larger image

Figure 1. The DataSet Conflicts Resolver application

With the sample application running, select the Create command from the Database menu. Use the displayed dialog box to specify a location to store the database files. You will need about 3 MB (megabytes) of disk space. Once you select a location, the Log On Information dialog is displayed as shown in Figure 2. First, enter your server name if localhost is not correct. Next, choose your authentication mechanism, entering your name and password, if necessary. This dialog box is necessary to validate your credentials. It caches your input for the duration of each session. Once you've specified the required information, click the Test Connection button to verify connectivity to your SQL Server installation. If the test succeeds, the OK button is enabled. Click it to continue.

click for larger image

Figure 2. The Log On Information dialog

At this point, a status dialog is displayed as the database is installed and the sample data inserted. Assuming a successful installation, the Get Data button is enabled, allowing you to retrieve data to edit locally in the displayed DataGrid control. In order to make it easy to create conflicting records in the database, the Simulate 2nd User button is enabled. You can click it now or first perform your own local edits. The records you will modify to ensure concurrency violations are those with an ID value of 10, 30, and 50. Modify the values in the Count columns for these rows. Once you have made the changes, click the Simulate 2nd User button (if you didn't earlier) and then click the Update button.

Assuming all goes well (how often are concurrency violations a sign of things going well?), you will receive a message box informing you of the conflicts (see Figure 3).

click for larger image

Figure 3. Data concurrency violation message box

Click OK. Each row that has a conflict is decorated with a red circle with an exclamation point inside at the beginning of each row, as shown in Figure 4.

click for larger image

Figure 4. Conflicts have occurred

To resolve the conflicts, select a row (see Figure 5) and click the Resolve Selected Row button.

click for larger image

Figure 5. A conflicting row

The Choose a Row to Keep dialog will appear as shown in Figure 6, displaying three versions of the row in question:

  1. Your modified version
  2. The server's current version
  3. The original version
click for larger image

Figure 6. The Choose a Row to Keep dialog

Decide which version of the row you would like to keep and click the appropriate button. The three choices are:

  • Keep your changes.
  • Accept the changes made by another user.
  • Rollback the row to its original state when you started.

These three options are represented by three command buttons labeled Keep My Changes, Keep Server Changes, and Keep Original Data. Each row is displayed in a read-only grid so you can compare each column for changes. Once you decide which row to keep, the form closes, allowing you to correct the remaining rows. Repeat until all rows no longer have the warning icon next to them.

Once all conflict-laden rows have been resolved, click the Update button again to submit the corrected rows. If there are no more conflicts, and there shouldn't be, you should receive a message stating that the updates were completed successfully. If you're all done, you can exit the sample application, reset the table's data to its starting state by selecting the Reload Data command from the Database menu, or completely remove the database by selecting the Remove Database command from the same menu.

The sample application was partitioned so that you can integrate sections of it into your own applications. In order to do that, you need to understand the architecture of the sample, as well as the inherent limitations in its design and implementation.

Application Design Issues

To provide a foundation upon which you can build later, the user interface and offline data management code have been separated from the code that actually talks to the database. In addition, support functions, such as the code necessary to install the database, load the sample data, and remove the database, are segmented into additional external DLLs. Open the DataSetConflicts.sln (if it's not already opened) and notice there are five projects—one Windows application and four class library.

The User Experience

The core UI is defined in the DataSetConflicts project. It contains three forms—frmMain (see Figure 1), frmDiffData (see Figure 4), and frmAbout. Obviously the UI design of frmMain is focused on this sample. I really doubt you would want to include a Simulate 2nd User button in your application. However, the other two forms could be useful in your own applications.

In order to enhance the user experience, the sample application stores configuration data on a user-by-user basis. .NET enabled applications support XML configuration files. The name of the file is the same as the assembly, with .config as the suffix. Normally, this file is stored in the same directory as its parent assembly. If this file exists, it is automatically loaded and parsed by the .NET runtime. Values stored under the appSettings element are available by using the ConfigurationSettings class defined in the System.Configuration namespace. The appSettings section of the configuration file stores name/value pairs defined in elements named add, with the name values stored in the key attribute and the value stored in the value attribute. These values are loaded into and become the contents of the ConfigurationSettings collection, which are read-only. This data is application data and is generally only changed by an administrator. User-specific information should be stored in a user-specific configuration file located in one of the standard directories made available by Windows.

For this sample application, the user configuration file is stored off the path returned from a call to the System.Environment.GetFolderPathSystem function, passing Environment.SpecialFolder.ApplicationData as the parameter. For example, on my Windows XP Professional machine, logged in using the local account Brian, the value returned is C:\Documents and Settings\Brian\Application Data. Using this as the base, the application appends the string defined in the constant CONFIG_FILE_DIR_BASE (\Microsoft\VB.NET Samples\{0}\v{1}\{2}), using String.Format to replace the format specifications with the appropriate values; application title for {0}, version number in for {1}, and the assembly name for {2}. Thus, on my machine, the configuration file is stored at C:\Documents and Settings\Brian\Application Data\Microsoft\VB.NET Samples\DataSet Conflicts Resolver\v1.0.0.0\DataSetConflicts.exe.config. Table 1 lists the data that could be stored in the user's XML configuration file by the sample application.

Table 1. User configuration file values

Key Name Default Sample Description
DBFilesExist False True True if the two sample database files exist; False otherwise.
DBPath "" C:\Samples The path where the two sample database files are stored.
DBServerName localhost localhost The name of the SQL Server hosting the sample database.
TrustedConnection True True True if a trusted connection is used to connect to SQL Server; False otherwise. If the value is False, the application prompts the user for their user ID and password the first time a connection is attempted and cached only for the duration of the application session.

The AppSettings class provides all of the necessary functions to load (and create, if necessary) the user's configuration file. The class abstracts the XML access method (the XML DOM) with a few simple procedures. In frmMain's Load event, an instance of the AppSettings class is created. Once the file has been loaded or created, the contents of the file are loaded into properties of the ConfigItems class. If the item does not exist, a default value is inserted as displayed in Table 1 above.

Once the configuration data has been loaded, the function CheckDBFilesExistance runs to verify the existence of the database files.

Private Function CheckDBFilesExistance() As Boolean
Try
Dim strDBFile As String = ConfigItems.DBPath & Me.DB_MAIN_FILE_NAME
Dim strLogFile As String = ConfigItems.DBPath & Me.DB_LOG_FILE_NAME
Dim blnRetVal As Boolean = _ 
File.Exists(strDBFile) AndAlso File.Exists(strLogFile)

mAppSettings.SetValue("DBFilesExist", blnRetVal.ToString())
ConfigItems.DBFilesExist = blnRetVal
mAppSettings.Save()

Return blnRetVal
Catch exp As Exception
MessageBox.Show(exp.Message, exp.Source, _ 
MessageBoxButtons.OK, MessageBoxIcon.Error)
End Try
End Function

Based upon the existence of the database files, the UI is setup. Note the FormatGrid method, which defines the look and feel of the main data grid. This routine is used later by frmDiffData to ensure a consistent appearance when viewing the data in grid form. At this point, there are two code paths—database management and data management. The database management code is interesting, but not the focus of this sample application. Feel free to explore it on your own. You'll find most of the database management code defined in the BuildDBSS project. The data management code is defined either in DataSetConflicts or DataSS.

Accessing the Database

The DataSS assembly encapsulates the ADO.NET code necessary to retrieve and update data stored in the sample SQL Server 2000 database. The client code, in this case frmMain, instantiates an instance of the SQLServer2000 class using an IExData interface reference (which is defined in the SyncDataIntf project). This design allows you to hook the UI up to another database, such as Microsoft Jet, without having to modify the client code too much.

How It Works

Now that you've had a chance to run the application and get a basic idea of how it's put together, you need to understand the core design issue of the sample application. How do you handle concurrency violations?

Getting the Data

The ADO.NET disconnected data model is centered upon the DataSet object. The DataSet object is a data source-agnostic object that can cache rectangular data in one or more DataTable objects. The DataSet object supports marshal-by-value semantics allowing it to be moved across tiers using .NET Remoting, Web services, or other protocol. In addition, you can serialize it into an XML format or a custom format using your own formatter. Although this sample application has been written and tested with all of the code running on the same machine, there is no reason why it could not be partitioned to take advantage of Web services, for example.

As mentioned earlier, an instance of the SQLServer2000 class is used for all data access in this sample application. The client application executes the GetDataDSCB function. The GetDataDSCB function comes in two overloaded versions—one that has six parameters, the other with seven. The common parameters are as follows:

  • SQL: A string containing the dynamic SQL that selects the records from the database.
  • DataSetName: A string used to name the DataSet.
  • DataTableName: A string used to name the DataTable.
  • PrimaryKeyName: A string defining the column name that represents the primary key column in the table.
  • RecordsReturned: A by-reference integer that will be set to the number of records retrieved from the database.
  • Return Value: A DataSet object containing the data retrieved from the database.

In addition, the seven-parameter version exposes a ConnectionString parameter. This version contains the core code for accessing the SQL Server database. First, the connection string is set. Next, the dynamic SQL string is validated using the ValidateSelectSQL function. This function performs a simple validation to make sure that all single quotes are correctly escaped. Feel free to enhance it as you see fit.

At this point GetDataDSCB will use the internal method, OpenDataSource, to initialize and a module-level SQLConnection instance. If successful, a SQLCommand object is created using the user's validated SQL and connection object. Next, a SqlDataAdapter is initialized using the previously setup SQLCommand object as input. Then, a local DataSet object is instantiated and named. Finally, the DataSet is filled with data using the Fill method of the SqlDataAdapter creating a named DataTable in the process. Once the dataset is loaded, a primary key is defined. Although the SqlDataAdapter does expose a FillSchema method, generally it should be avoided for performance reasons. In addition, there aren't any relations or constraints to deal with in this sample. With the Dataset filled, the method returns it to the client caller where it can be manipulated.

Client-side Updating

Once the client has retrieved the data, a module-level DataTable instance is set. The sample has an event handler hooked to the DataTable's ColumnChanged event. This event updates the LastUpdatedBy and LastUpdatedDate columns when a user changes an editable column. Note that these two columns were specifically added to the design of the table in order to help you see who has changed a record and caused a concurrency violation. Next, a CurrencyManager object is acquired. This object exposes a PositionChanged event that is used to make sure that the UI is in the appropriate state when the user navigates from row to row using the data grid. Once the CurrencyManager is hooked, the code accesses the current DataRowView object in order to get a hold of the DataView being used by the CurrencyManager. A reference, mdvCM, is stored away so that later you can change which records are displayed in the grid by using the cboRowFilter combo-box.

At this point, there are a few things left to do. First, three module level DataView objects are initialized for latter use if viewing the data changes. Second, the retrieved data is bound to the grid using the DataSource and DataMember properties. Third, the cboRowFilter's SelectedItem is set to the mdvCM's current RowStateFilter, which defaults to CurrentRows. All that is left to do now is update the user interface. The most interesting routine is DisplayRowInfo. This routine is called by the CurrencyManager's PositionChanged event and as necessary by other routines such as the btnGetData_Click event.

Private Sub DisplayRowInfo()
   If Me.mobjCurrMgr.Position = -1 AndAlso Me.mdt.Rows.Count > 0 Then
      Me.mobjCurrMgr.Position = 0
   End If

   Dim localRow As DataRow = _ 
CType(Me.mobjCurrMgr.Current, DataRowView).Row
   mstrCurrentRowFilter = _ 
String.Format(Me.BASE_ROW_FILTER, Me.mstrPrimaryKeyName, _ 
localRow(Me.mstrPrimaryKeyName).ToString())

   Const BASE_STATUS_TEXT As String = "Current Row's State: {0}"

   Me.lblStatus.Text = _ 
String.Format(BASE_STATUS_TEXT, localRow.RowState.ToString())

   If localRow.HasErrors Then
      Me.lblStatus.Text &= vbCrLf & "Error: " & _ 
localRow.RowError & vbCrLf & _ 
"Click the 'Resolve Selected Row' button to fix."
If mdsConflicts Is Nothing Then
Me.btnResolve.Enabled = False
Else
         Dim svrRow As DataRow
Try
Dim objKey As Object = _ 
CType(localRow(Me.mstrPrimaryKeyName), Object)
svrRow = mdsConflicts.Tables(0).Rows.Find(objKey)
         Catch exp As Exception
MessageBox.Show(exp.Message, "DisplayRowInfo", _ MessageBoxButtons.OK, _ 
MessageBoxIcon.Error)
         End Try
         Me.btnResolve.Enabled = (Not svrRow Is Nothing)
      End If
   Else
      Me.btnResolve.Enabled = False
   End If

   Me.btnUpdate.Enabled = ((Not localRow.Table.HasErrors) _ 
AndAlso (localRow.Table.DataSet.HasChanges))

   Me.BindingContext(Me.mdvOriginal).Position = Me.mobjCurrMgr.Position
End Sub

DisplayRowInfo first checks to see whether there is a current row. A row filter string is created and stored in the mstrCurrentRowFilter variable for later use when comparing the local data with the server data. The next block of code deals with conflicts. Each DataRow object has a HasErrors property that has been set to True by the server updating code (more on that later in the section Getting the Latest Version From the Server). If this property is True, a check is made to see if the mdsConflicts DataSet has been initialized. If it has, then an Object variable is created containing the value of the current row's primary key. This value is used to locate a matching row that contains the latest version from the server. This is accomplished using the Find method of a DataTable's Rows collection. The DataTable that will be used is stored in mdsConflicts. This dataset is filled with rows that had conflicts when the DataSet was updated (more on this later also in Getting the Latest Version From the Server). If a conflict row is found, then the Resolve Selected Row button is enabled. Once the row has been evaluated for conflicts, the Update button's enabled property is set based upon the main DataSet's having changes and whether or not there are any errors still in the current row's DataTable.

As mentioned, DisplayRowInfo is called whenever the user navigates between rows. Thus, once a row has been changed, the Update button is enabled, allowing changes to be sent to the server.

Sending Changes to the Server

Packing changes up to send to the server is done in the btnUpdate_Click event handler. First the mds DataSet is double-checked to see if changes exist. Then, for the purposes of this sample, a check is made to see if you've clicked the Simulate 2nd User button. The UpdateTableDSCB method off the mdata interface reference is called passing the mds DataSet object containing the changed data and the name of the DataTable to update as parameters. This method returns a DataSet object containing conflicting rows (if any) and stores them in the client's mdsConflicts object. Once the method completes a check is made to see whether there were any errors by examining the HasErrors property of the mds DataSet object. If errors exist, it is up to you to decide the outcome. If there are no errors, the DataSet's AcceptChanges method is called to set the modified records as being current, but before this can happen, the actual update has to occur.

Inside UpdateTableDSCB

The first task that must be accomplished is checking to see if the SqlDataApdater, mdsa, has a valid UpdateCommand. The UpdateCommand specifies how changes will be submitted. You can provide your own custom SQL, a stored procedure, or your can have the ADO.NET SqlCommandBuilder create a dynamic SQL string for you based upon the SELECT statement used earlier to fill the DataSet. This sample uses the SqlCommandBuilder. Once the builder object is created, its GetUpdateCommand method is called, assigning the return value to the msda object's UpdateCommand property. Now that everything is about ready to send your updates to the server, one other task needs to be accomplished.

Unlike the ADO Recordset object, the ADO.NET DataSet object does not provide a Resync method. This method can be used to synchronize your local cache with the changed records from the server—handling conflicts is definitely a do-it-yourself scenario. To make this happen, you first need a second SqlDataAdapter to retrieve conflicting rows. The code in this sample application provides a parameterized SQL statement, the success of which is based upon the assumption that the primary key column will not be changed (generally a good assumption, but alas you know what they say about assumptions). Once the second module-level data adapter is setup and named msdaCon, a second DataSet object, mdsCon, is created.

You're almost ready to actually send the changes to SQL Server, but one last task is required. By default, ADO.NET sends each changed record one at a time to the server. If an error occurs, it stops sending data and throws an exception. The problem with this is that some records could have already been successfully updated. Many developers erroneously assume that the SqlDataAdapter performs a transactional, batch update. You can do this, but you must manually create a transaction object and manage the transaction yourself. The problem with using a transaction is that there is no way for the user to decide the outcome on a row-by-row basis. The transaction is all or nothing. One conflicting row, and poof, the update process is stopped and all changes to the database are rolled back. The key to success is to have the SqlDataAdapter try to update each row regardless of errors. When a row does have an error, the conflicting record is retrieved from the server so you can decide what to do. To get this non-default behavior, the ContinueUpdateOnError property of the SqlDataAdapter object (in this case msda) must be set to True. Once this is done, the Update method can be called.

Getting the Latest Version from the Server

The only way to make thisdo-it-yourself update work is to know which records failed to update. Luckily (or is it by-design?), the SqlDataAdapter object exposes a set of events that can be used to monitor the update process. You're interested in the RowUpdated event. This event fires after every row has been updated. You're really only interested in rows that have failed to update. To figure this out, you'll use the SqlRowUpdatedEventArgs object's Status and Errors properties. If the Status equals ErrorsOccured and the Errors instance is a System.Data.DBConcurrencyException, it tells you that you need get the conflicting record from the server. This is done using the earlier initialized second data adapter msdaCon. Using the current row's primary key value, you set the only parameter exposed by the data adapter's SelectCommand's parameters collection. The Fill method is executed, returning the row in question. After that, the error row is marked as having an error by initializing its RowError property. Last but not least, you need to tell the msda data adapter to continue processing by setting the e.Status property to Continue.

Choosing the Version You Want to Keep

When the update command has finished, the UI is updated to the appropriate state by checking to see if the dataset you sent up to the server had errors. If it does, controls are enabled and disabled as necessary. In addition, the data grid turns on the row error indicator icon to show which rows had conflicts. All you have to do is provide a way for the user to see the errors. The data grid itself can show you either the modified records with their current values or the modified records with their original values. Unlike the ADO Recordset object, there is no provision to have the dataset show the third condition—the current server values. It makes sense that the user would like to see all three versions at once. That's exactly what frmDiffData does.

When the user selects a row that has an error indicator, the Resolve Selected Row button is enabled. Clicking it causes and instance of frmDiffData to be initialized and the conflicting row's data is passed to it. Three DataView objects are passed to the frmDiffData's LoadGrids method. One is filtered to show the current row with original row data. A second shows the current row's data in its modified state. These two DataViews are based upon the mds DataSet object. The third DataView is based on the conflicts DataSet, mdsConflicts. Each view is passed to the local SetDiffView method to filter the records down using the RowFilter property, as well as setting editing options. Once the grids are loaded, the form is displayed in dialog mode.

The user now can see the conflicting row in its three states. The grid labeled My Changes allows the user to interactively adjust values if another change is necessary. The other two grids are read-only. The user clicks one of the three buttons to pick the version of the row they want. Each button's event handler processes the row resolution in a similar but specific format.

If Keep My Changes is clicked, the current row has to be reformed. The key to setting the row up is to get it into a state where the SqlDataAdapter can find the row on the server and send the changes. As mentioned earlier, this sample application uses the SqlCommandBuilder object to define the update command. This SQL string defined creates a SQL WHERE clause specifying that all columns must match. The current values from the server need to be loaded into the current row so that they appear as the original values. The changes then need to be layered on top of the "new" original values. To do this, the current modifications and the current server values are copied from their respective row objects into independent arrays, currData and svrData. Once this is done, the data row object is told to rollback the modifications using the RejectChanges method and to reset its error status by calling ClearErrors. At this point, the row looks like it did before the local user made any changes. Now the original data is copied over by shoving the data (stored in the svrData array) from the server into the row using the row's ItemArray method. AcceptChanges is then called to make the new modifications look like the current values. Finally, the data stored in currData, the user's real changes, are passed to the row through the ItemArray method and the form is closed.

The process of accepting the most current server changes, which is what occurs if the Keep Server Changes button is clicked, is very similar. The main difference is that you don't care about the existing changes to the row. They are thrown out in favor of the server data. An array named svrData is created containing the current server values. The row with the current modification is told to dump its changes and reset its error flag. The server data is copied into the row using ItemArray. Then, unlike the Keep My Changes code, AcceptChanges is called and the form is closed. The user now sees the latest values in the main form's data grid.

The final option is to rollback the row to its original state, throwing out the user's local changes as well as any changes made by another user. This procedure works almost exactly like the code in Keep My Changes with one big caveat. When the current row's data is copied into the temporary array, the ItemArray method is used. The problem is that you need the values from the DataRowView. This object does not have an ItemArray method, so the code just does the copy manually. From there it's all about the same. Make the server data the original data and then put the real original data on top of the server data to make it look like modifications, and close the form.

This process is repeated until all rows have been resolved. Once there are no more errors, the Update button on the main form is enabled again, allowing the changed rows to be re-submitted. The process is repeated until there are no more errors or until the user gets tired of resolving conflicts and closes the application.

Conclusion

As you examine the code in the sample application, remember that this is one focused example in a very large world of possibilities. It has only touched on the possibilities of what can be done with Visual Basic® .NET, ADO.NET and the .NET Framework. As an exercise, you might try changing the data access code to use Microsoft Jet or some other data source. You might also try changing the data access code to use stored procedures instead of dynamic SQL.

References

For additional information, see the documentation that comes with the .NET Framework SDK and/or Visual Studio .NET. In addition, if you've got some extra money burning a hole in your pocket, or your family keeps bugging you for holiday gift ideas, there are two ADO.NET books that I especially like:

  • Microsoft ADO.NET by David Sceppa [Microsoft Press, 2002, 0735614237]
  • Essential ADO.NET by Bob Beauchemin [Addison Wesley Professional, 2002, 0201758660]
Show:
© 2015 Microsoft