Data
and the Desktop
LANGUAGES: C#
ASP.NET VERSIONS: 1.0 | 1.1| 2.0
Typed DataSets
Create Maintainable Business Object Collections
By Brian Noyes
When you
design a program for .NET, it's sometimes difficult to decide whether to
implement business layer classes for all the data objects and collections of
objects your program needs, or to simply use DataSet objects to contain the data. After all, the state of those
objects usually gets populated from some form of data store, such as a database
or an XML file, and DataSets are
easy to populate from either. DataSets
are also especially attractive for binding to data-aware controls such as the
.NET DataGrid classes, because those
controls have built-in support for display and manipulation of multiple tables
in a DataSet (although only the WinForms control supports hierarchical binding).
However,
there are two big downsides to using DataSet
objects instead of business object classes. The first is that by coding
directly against the tables and fields of the data contained in a DataSet, you're tightly coupling your
code to the underlying schema of the data you're working with. If the schema of
the query or file you populated that DataSet
with changes, your consuming code breaks and you won't have any indication of
that breakage until run time.
The
second big problem is that when you're working with raw DataSets, you're working with weakly typed data. When you read or
write data from or to a DataSet, you
use the Item property on the DataRow class (usually implicitly using
the indexer on an instance of a DataRow).
The Item property just returns an
object reference to the underlying contents of the field indicated by the index
or name passed to Item. As far as
the compiler is concerned, you can try to stuff any data type into a field, or
pull any type out, because all data types in .NET derive from the Object base class. Using this approach,
therefore, you won't be able to detect type incompatibilities until run time -
a situation you want to avoid now that you're working in .NET's strongly typed
environment.
Enter Typed DataSets
Using
typed DataSets can address both of
these problems to a large degree. Typed DataSets
are classes in .NET that you generate using the framework tools from an XSD
schema. You can create them most easily using the Visual Studio.NET design
environment, but you can also create them from a command line using the xsd.exe
SDK tool.
When you
use the tools to create a typed DataSet,
a set of classes is generated that gives you a strongly-typed data model for
coding against the underlying data. This generated code file contains a
top-level class that represents the DataSet
itself. Within that class there are nested class types for each table the DataSet contains. Within those classes,
there are nested type definitions for the rows of the table. Finally, within
the row class for each table, there are named properties exposing each field as
a strongly-typed data member. As a result, when you code against the properties
of the typed DataSet and its contained
class definitions, you'll get compile-time type checking for accessing the
tables, rows, and fields of the DataSet
with which you're working.
I've
given you a concrete example of how to read and write data from the Orders and
Order Detail tables from the Northwind sample database, which comes with SQL
Server, to give you a better idea of how you code against a typed DataSet. To create a typed DataSet to contain both the Orders and
Order Details table with a parent/child relationship between them, you first
need an XSD schema. You can either handcraft one with the appropriate elements
and types, or you can let VS.NET do the work for you.
Next you
want to create SqlConnection and SqlDataAdapter objects. Open Server
Explorer and either create a Data Connection to the Northwind database, or
navigate to it through the Servers node. Open the Northwind
Tables node so that the Orders and Order
Details tables are visible. You will then need a design surface to drag them
onto. Create a new component class in your project (use the Add Component feature from the Project menu), and drag the
Orders table from the Server Explorer tree onto the design surface of the
component class.
Now that
you've created the SqlConnection and
SqlDataAdapter objects, right-click
on the SqlDataAdapter created and
select Generate Dataset from the context menu. This brings up a dialog box (see
Figure 1). Enter a name for the new DataSet,
and an XSD schema file and underlying typed DataSet code file will be added to your
project (see Figure 2). You'll need to select Show
All Files in Solution Explorer to see the DataSet code file nested under the XSD
file created for the typed DataSet.
You can then repeat the process to add the Order Details table to the same
typed DataSet by selecting Generate Dataset on the Order
Details SqlDataAdapter.
Figure 1: After selecting Generate Dataset with a SqlDataAdapter
selected in the designer, you can choose whether to create a new typed DataSet or to add the data from the
DataAdapter to an existing DataSet.
Figure 2: A typed DataSet
is generated from an XSD Schema file that contains element definitions for the
tables the typed DataSet will
contain. When you generate a DataSet
from a DataAdapter in VS.NET, the XSD file is generated for you, along with a
linked code file that contains the .NET class definitions to code against the
typed DataSet.
If you
inspect the code generated for you in the DataSet
code file (nested under the XSD file in Solution Explorer), you'll see classes
that define an object model (see Figure 3). You have two choices for adding a
relationship between the Orders and Order Details tables within the typed DataSet. The first is to simply create
the relation in code when you populate the DataSet
by adding a DataRelation to the Relations collection on the DataSet. The second is to edit the XSD
schema to add an XSD relation between the Orders and Order Details elements in
the schema, based on the OrderID element in each table. Doing the latter
automatically adds the code to the typed DataSet
class, adding a DataRelation and a
foreign key constraint between the columns of the two tables. This is the
approach I use in the sample code.
Figure 3: A typed DataSet
contains type definitions for the DataSet
itself, each of the tables it contains, a type for the rows of the table, and
properties on the row type to encapsulate the fields in strongly typed members.
Fill a Typed
DataSet
To fill a
typed DataSet once it's defined, you
just call Fill on the SqlDataAdapter that's set up to
populate the table (see Figure 4). Likewise, updating the database from the DataSet simply uses the Update method of the SqlDataAdapter, passing in the DataSet reference. This works because
the typed DataSet is, in fact, a DataSet itself through inheritance from
the base class. The SqlDataAdapter
doesn't know anything about your typed DataSet
class, but it doesn't have to. It will use the methods and properties of your DataSet's base
class to do business as usual on a DataSet.
Behold the polymorphic power of inheritance.
public static OrdersDataSet GetOrders()
{
// Create DBObjects component to talk to
database.
DBObjects dbo = new DBObjects();
// Construct the data set
OrdersDataSet ds = new OrdersDataSet();
dbo.OrdersDataAdapter.Fill(ds,
"Orders");
dbo.OrderDetailsEnhancedDataAdapter.Fill(
ds, "OrderDetails");
return ds;
}
public static void UpdateOrders(OrdersDataSet dsOrders)
{
DBObjects dbo = new DBObjects();
dbo.OrderDetailsEnhancedDataAdapter.Update(
dsOrders.OrderDetails);
}
Figure 4: To fill a typed DataSet,
just call SqlDataAdapter.Fill,
passing in the DataSet reference and
the name of the table. The table name will have to correspond to the name of
the table in the XSD schema so that it matches the bindings in the typed DataSet code. To update it, just pass
the DataSet or DataTable into the Update
method of the SqlDataAdapter.
As I've
mentioned, the typed DataSet derives
from the DataSet as a base class, so
you can easily access the underlying capabilities of the DataSet. For example, you might want to save or load the contents
of your DataSet to or from an XML
file. However, you should avoid using DataSet
base class properties such as the Rows
collection, because they give you type-unsafe access to the underlying data,
negating some of the benefits of the typed DataSet.
So, for example, if you were trying to extract the OrderID value for a row from
the DataSet, the untyped approach
would do this:
DataRow row =
m_dsOrders.Tables["Orders"].Rows[i];
int orderId = (int)row["OrderID"];
With a
typed DataSet, you can simply do
this (notice that no casts are involved, and no hard-coded schema name values
exist):
int orderId = m_dsOrders.Orders[i].OrderID;
The
download code contains a sample application using the typed DataSet I've just described to retrieve
orders and order details from the Northwind database. It binds the Orders table
to a grid, and allows you to add order items to an order. It uses the
strongly-typed properties to populate the new rows of order details (see Figure
5). The sample application also demonstrates that you can generate DataSets from stored procedures just as
easily as you can for raw tables.
private void btnAdd_Click(object sender,
System.EventArgs e)
{
int productId;
decimal unitPrice;
short quantity;
float discount;
try // Extract the values from form controls.
{
productId = int.Parse(cmbProduct.SelectedItem.Value);
unitPrice = decimal.Parse(txtUnitPrice.Text);
quantity = short.Parse(txtQuantity.Text);
discount = float.Parse(txtDiscount.Text);
}
catch (Exception ex)
{
Response.Write("Invalid entry:
"+ex.Message);
return;
}
// Get the data set out of session.
OrdersDataSet dsOrders =
Session["OrdersDataSet"]
as OrdersDataSet;
// Now populate a new row for the DataSet.
OrdersDataSet.OrderDetailsRow newRow =
dsOrders.OrderDetails.NewOrderDetailsRow();
newRow.OrderID = m_parentOrderId;
newRow.ProductID = productId;
newRow.ProductName =
cmbProduct.SelectedItem.Text;
newRow.UnitPrice = unitPrice;
newRow.Quantity = quantity;
newRow.Discount = discount;
// Add it to the DataSet.
dsOrders.OrderDetails.AddOrderDetailsRow(newRow);
Server.Transfer("OrderForm.aspx");
}
Figure 5: You can access fields in a typed DataSet through the strongly-typed properties of the OrderDetailsRow class, which is derived
from DataRow and defined as a nested
class of the OrdersDataSet class.
The
sample uses an enhanced version of the Order Details table that contains the
ProductName as well as the ProductID for presentation purposes. To achieve
this, the Order Details table within the OrdersDataSet
was created based on a stored procedure instead of a raw table. The steps to
add the results of the stored procedure as a table to the DataSet are quite similar to what I described before. The only
difference is that the SqlDataAdapter
that you use to add the table to the DataSet
is one you create by dragging the stored procedure out to the designer surface
from Server Explorer, rather than dragging a table out. I also had to handcraft
a SqlCommand object to perform the
inserts for new Order Detail items so that it would ignore the ProductName, and
associated that command object with the InsertCommand
property for the SqlDataAdapter.
When you
add a table to a typed DataSet from
a SqlDataAdapter that uses a stored
procedure to retrieve the data, the table in the DataSet will be named the same as the stored procedure with "Table"
appended to it. For example, if the stored procedure is named GetCustomers, the resulting typed DataSet table is named GetCustomersTable. If you want to
change this to something else (such as CustomersTable),
just edit the top-level element name for the table in the XSD file to set it to
whatever you'd like. In this example, you would change the element name from GetCustomers to Customers.
Maintaining Typed DataSets
Earlier,
I mentioned that one of the downsides to using basic DataSets was the fact that your consuming code is coupled to the
data's underlying schema. At this point, you might be thinking that with typed DataSets, your code is just as coupled
to the underlying schema as before. And you would be right. However, typed DataSets have a distinct advantage in
managing changes to the underlying schema, resulting directly from the fact
that they are typed.
The
advantage comes from the fact that if the schema changes, you can simply
regenerate the typed DataSet code
using VS.NET or the xsd.exe tool. Once you do that and recompile your project,
you'll get immediate, precise feedback about which lines of code are affected
in the form of compiler errors. Because of changing schemas, you're able to
make the required corrections faster, and with fewer errors, than if you had to
try to root out all the corrections needed when programming against the untyped
DataSet. So with a typed DataSet, you're still tightly coupled to the underlying schema, but it
becomes a little easier to tolerate that fact from a maintenance standpoint.
However, if you code in a world where the schemas are volatile, do not map well
to the logical constructs of your application, or are not within your
development team's control, you might want to consider defining business object
classes that are completely decoupled from the underlying schema, using a data
mapping pattern to populate the business object state from data.
The other
thing to keep in mind is that you should avoid directly modifying the
machine-generated code for the DataSet.
If you do modify the source directly, you'll need to keep close track of the
changes you made so that you can integrate those changes when you regenerate
the code. In some cases you might be able to derive classes from the typed DataSet and nested classes to modify
their behavior. In most cases, you'll be better off wrapping the typed DataSet in a container class, and
having the container class expose the modifications you need.
Unfortunately,
there are still situations where you will have to deal with late bound access
to the data in your typed DataSets.
One is when you're working with DataViews. There is no corresponding typed DataView class, so if you're working
with your data through a DataView for sorting or filtering, you'll have to step
back into the late bound access by field name or index, as with untyped DataSets. The other place is when
you're dealing with data binding with windows or Web form controls. Data
binding in .NET is done in a late-bound fashion, and in many cases you have to
pass a field name as a string to specify the column to bind to.
Conclusion
Typed DataSets provide a clean coding model
for working with DataSets that can
improve the maintainability of your code through strong typing and easy
re-creation of the DataSet code when
the underlying schema changes. They enable you to achieve the strongly typed
and object-oriented benefits of custom business object collections, without
sacrificing the ease and flexibility of the DataSet object. They are not a panacea for all scenarios, but I
recommend you always try to use typed DataSets
instead of raw DataSets, except for
toy projects or localized uses of a DataSet.
The sample code in this
article is available for download.
Brian
Noyes is a software architect with IDesign, Inc. (http://www.idesign.net), a .NET-focused
architecture and design consulting firm. Brian specializes in designing and
building data-driven distributed Windows and Web applications with .NET. He has
more than 12 years of experience in programming, engineering, and project
management, and is a contributing editor for asp.netPRO,
and other publications. Contact him at mailto:brian.noyes@idesign.net.