Tip | Because IDataReader is a very large interface, we won't include all the code for the class in the book's text. The complete class is available in the code download associated with the book. |
Add a new class to the CSLA project and name it SafeDataReader . Let's start with the following code:
using System; using System.Data; namespace CSLA.Data { public class SafeDataReader : IDataReader { IDataReader _dataReader; public SafeDataReader(IDataReader dataReader) { _dataReader = dataReader; } } }
We put the class in the CSLA.Data.SafeDataReader namespace. This helps keep the class organized within our framework.
We start by declaring that we'll implement the IDataReader interface ourselves in this class. The class also defines a variable to store a reference to the real data reader that we'll be encapsulating, and we have a constructor that accepts that data reader object as a parameter.
To create an instance of SafeDataReader , we need to start with an existing data reader object. This is pretty easy ”it means that our ADO.NET code might appear as follows :
SafeDataReader dr = new SafeDataReader(cm.ExecuteReader);
A command object's ExecuteReader() method returns a data reader object that we can use to initialize our wrapper object. The remainder of our data access code can use our SafeDataReader object just like a regular data reader object, because we've implemented IDataReader .
The implementation of IDataReader is a lengthy business ”it contains a lot of methods ”so we're not going to go through all of it here. However, we should look at a couple of methods to get a feel for how the thing works.
All the methods that return column data are "null protected" with code like this:
public string GetString(int i) { if( _dataReader.IsDBNull(i)) return string.Empty; else return _dataReader.GetString(i); }
If the value in the database is null , we return some more palatable value ”typically, whatever passes for "empty" for the specific data type. If the value isn't null , we simply return the value from the underlying data reader object.
There really are a lot of these methods. There has to be one for each data type that we might get from the database!
Most types have "empty" values that are obvious, but two are problematic : DateTime and Boolean . The former, for instance, has no "empty" value:
public DateTime GetDateTime(int i) { if(_dataReader.IsDBNull(i)) return DateTime.MinValue; else return _dataReader.GetDateTime(i); }
Here, we're arbitrarily assigning the minimum date value to be the "empty" value. This hooks into all the issues that we discussed in Chapter 4, when we created the SmartDate class. We'll provide alternative methods in SafeDataReader that utilize the SmartDate class:
public CSLA.SmartDate GetSmartDate(int i) { if(_dataReader.IsDBNull(i)) return new CSLA.SmartDate(false); else return new CSLA.SmartDate(_dataReader.GetDateTime(i), false); } public CSLA.SmartDate GetSmartDate(int i, bool minIsEmpty) { if(_dataReader.IsDBNull(i)) return new CSLA.SmartDate(minIsEmpty); else return new CSLA.SmartDate(_dataReader.GetDateTime(i), minIsEmpty); }
Our data access code can now choose either to accept the minimum date value as being equivalent to "empty" or to retrieve a SmartDate object that is more intelligent :
SmartDate myDate = dr.GetSmartDate(0);
or
SmartDate myDate = dr.GetSmartDate(0, false);
Likewise, there is no "empty" value for the bool type:
public bool GetBoolean(int i) { if(_dataReader.IsDBNull(i)) return false; else return _dataReader.GetBoolean(i); }
As you can see, we arbitrarily return a false value in this case. You may need to alter the return value of GetBoolean() (or indeed GetDateTime() ) to suit your specific application requirements.
The IDataReader interface also includes a number of methods that don't return column values, such as the Read() method:
public bool Read() { return _dataReader.Read(); }
In these cases, we simply delegate the method call down to the underlying data reader object for it to handle. Any return values are passed back to the calling code, so the fact that our wrapper is involved is entirely transparent.
The SafeDataReader class can be used to simplify our data access code dramatically, anytime we're working with tables in which null values are allowed in columns where we really don't want them or care about them. If your application does care about the use of null values, you can simply use the regular data reader objects instead.
We'll make use of SafeDataReader in Chapter 7 as we implement our business objects.
The last class we'll implement in this chapter ”and in the business framework ” is NameValueList . This is a base class on which we can easily build name-value lists for our applications.
Many, if not most, applications need read-only name-value lists to populate combo box controls, list box controls, etc. Typically, these lists come from relatively static configuration tables that are separately managed. Examples of such lists might be a list of product types, customer categories, valid shipping methods, and so forth.
Rather than putting in a lot of effort to implement specific read-only collection objects for each type of list in an application, it would be nice if there were an easy way to create them quickly ”preferably with little or no code. We won't achieve no code here, but by implementing the NameValueList base class, we'll be able to implement read-only name-value list objects with very little code indeed. The NameValueList class will provide core functionality:
Retrieve a value, given a name.
Retrieve a name, given a value.
Retrieve a copy of the entire list as a NameValueCollection .
Implement generic data access to populate the list.
Data access can be overridden with custom code, if needed.
This functionality should be sufficient to provide most of the name-value collection behaviors that we need in business applications. To create a specific name-value collection object, we can subclass NameValueList , provide an object-specific Criteria class and a static creation method, and we're pretty much done. All the hard work will be done by the base class we're about to create.
Our end goal is to make it so that the business developer can create a name-value list class with very little code. When we're done, the business developer will be able to create a typical name-value list like this:
[Serializable()] _ public class MyDataList : NameValueList { #region Static Methods public static MyDataList GetMyDataList() { return (MyDataList)DataPortal.Fetch(new Criteria()); } #endregion #region Constructors private MyDataList() { // Prevent direct creation } // This constructor overload is required because the base class // (NameObjectCollectionBase) implements ISerializable protected NameValueList(SerializationInfo info, StreamingContext context) : base(info, context) { // we have nothing to serialize } #endregion #region Criteria [Serializable()] _ private class Criteria { // Add criteria here } #endregion #region Data Access // Called by DataPortal to load data from the database protected override void DataPortal_Fetch(object Criteria) { SimpleFetch("myDatabase", "myTable", "myNameColumn", " myValueColumn"); } #endregion }
The resulting business object will be similar to a read-only version of a regular .NET NameValueCollection . It will load itself with data from the database, table, and columns specified.
Tip | The extra private constructor is a bit odd. It turns out that when a class implements the ISerializable interface, any class that inherits from it must implement a special constructor, as shown here. The NameObjectCollectionBase class from which we'll ultimately inherit implements ISerializable , forcing us to write this extra code. Presumably, the default serialization provided by [Serializable()] was insufficient for NameObjectCollectionBase , so Microsoft had to implement ISerializable , leaving us to deal with this unfortunate side effect. |
While we already have a ReadOnlyCollectionBase class on which we can build read-only collections, it's not particularly useful when we want to build a name-value object. A name-value collection is different from a basic collection, because it not only stores values like a collection, but also it stores names associated with each of those values. Because ReadOnlyCollectionBase is a subclass of CollectionBase , it isn't well suited to acting as a name-value collection.
However, by creating a subclass of NameObjectCollectionBase that integrates with our CSLA .NET framework, we can easily create a base class that provides read-only name-value data that can be loaded from data in a database table. We can think of NameValueList as being a peer to ReadOnlyCollectionBase within our framework.
Add a new class to the CSLA project and name it NameValueList . As usual, we'll be using a number of namespaces, so let's use them now:
using System; using System.Data.SqlClient; using System.Collections.Specialized; using System.IO; using System.Runtime.Serialization; using System.Runtime.Serialization.Formatters.Binary;
Then we can start creating the class, which will be very like the ReadOnlyBase and ReadOnlyCollectionBase classes from Chapter 4. It will be serializable, and it will implement the ICloneable interface. To get all the prebuilt collection behaviors to track both names and values, we'll inherit from NameObjectCollectionBase :
[Serializable()] abstract public class NameValueList : NameObjectCollectionBase, ICloneable { #region ICloneable public object Clone() { MemoryStream buffer = new MemoryStream(); BinaryFormatter formatter = new BinaryFormatter(); formatter.Serialize(buffer, this); buffer.Position = 0; return formatter.Deserialize(buffer); } #endregion }
The NameObjectCollectionBase provided by .NET is a lot like the other collection base classes, in that it manages the collection of data internally, leaving it up to us to implement methods such as Item() and Add() . In fact, NameObjectCollectionBase is even nicer, because it doesn't automatically expose methods like Remove() , RemoveAt() , or Clear() , so we don't have to write extra code to make our object read-only.
Add the following code to the class:
#region Collection methods public string this [int index] { get { return (string)base.BaseGet(index); } } public string this [string name] { get { return (string)base.BaseGet(name); } } protected void Add(string name, string newvalue) { base.BaseAdd(name, newvalue); } public string Key(string item) { foreach(string keyName in this) if(this[keyName] == item) return keyName; // we didn't find a match - throw an exception throw new ApplicationException("No matching item in collection"); } #endregion
Here, we've implemented two indexers so that we can retrieve a value based either on its text key value or by its numeric location in the list. This mirrors the behavior of the .NET NameValueCollection .
We've also implemented a Key() property that returns the text of the key that corresponds to a specific value. The rationale behind this is that we often load a list control with values and allow the user to select one. When that happens, we need to convert that value back to the key, since it's typically the key that's important to the database. The value itself is typically human-readable text, which is nice for display but useless for storage.
If an invalid value is supplied to Key() , we'll throw an exception. This is a powerful validation tool, because it means that we can write very simple code to validate user entry. The business logic might look like this:
_key = myList.Key(_userInput);
If the user input isn't a valid item in our list, this will result in an exception being thrown.
Finally, we included an Add() method. Notice that this is protected in scope, so only subclasses based on our base class can add data to the collection. We don't want to allow other business objects or the UI to change the data in the collection ” it should be loaded from the database, and then be read-only.
We need to implement two constructor methods, which is a bit different from what we've seen so far. We'll explain them following the code:
#region Create and Load protected NameValueList() { // prevent public creation } protected NameValueList(SerializationInfo info, StreamingContext context) : base(info, context) { // we have nothing to serialize } #endregion
The first constructor is a default one that allows the business developer to inherit from our base class. Normally, we don't need to include default constructors like this, because the C# compiler creates them for us. However, if we write a parameterized constructor, the compiler won't generate a default, so we must add it explicitly.
The second constructor is required because, as stated earlier, the NameObjectCollectionBase class implements the ISerializable interface. When a class implements this interface, it must provide a special constructor that the serialization infrastructure can call to deserialize the object. Unfortunately, this also means that any classes that inherit from a class that implements ISerializable must also implement this specific constructor, delegating the call to the base class itself.
As with all our other business base classes, we must provide the DataPortal_xyz() methods. Since we're creating a base for read-only objects, the only method we need to make non- private is DataPortal_Fetch() . That's the only one the business developer needs to worry about overriding:
#region Data Access private void DataPortal_Create(object criteria) { throw new NotSupportedException("Invalid operation - create not allowed"); } virtual protected void DataPortal_Fetch(object criteria) { throw new NotSupportedException("Invalid operation - fetch not allowed"); } private void DataPortal_Update() { throw new NotSupportedException("Invalid operation - update not allowed"); } private void DataPortal_Delete(object criteria) { throw new NotSupportedException("Invalid operation - delete not allowed"); } protected string DB(string databaseName) { return ConfigurationSettings.AppSettings["DB:" + databaseName]; } #endregion
Now the business developer can provide an implementation of DataPortal_Fetch() to load the collection with name-value pairs from his database or from some other data store (such as an XML file).
The code to load a list with name-value pairs is often very generic. Rather than forcing the business developer to write the same basic code over and over, we'll implement a helper function that she can use to do most name-value list data access. This means that the implementation of DataPortal_Fetch() in a list object will often be just one line of code that calls into the base class code we'll implement here, for instance:
protected override void DataPortal_Fetch(object Criteria) { SimpleFetch("myDatabase", "myTable", "myNameColumn", " myValueColumn"); }
For cases where this generic helper routine can't retrieve the data, the business developer will need to write his own data access code in DataPortal_Fetch() . This approach allows total customization of data access, if that's what's required, while allowing most list objects to contain virtually no data access code at all.
The generic helper function, SimpleFetch() , will retrieve a list of name-value data. It accepts the name of the database and the table from which the data will come as parameters. It also accepts the column names for the name and value columns. Given this information, it connects to the database and runs a simple SELECT query to retrieve the two columns from the specified table.
Add the following code to the class within the Data Access region:
protected void SimpleFetch(string dataBaseName, string tableName, string nameColumn, string valueColumn) { using(SqlConnection cn = new SqlConnection(DB(dataBaseName))) { cn.Open(); using(SqlCommand cm = cn.CreateCommand()) { cm.CommandText = "SELECT " + nameColumn + "," + valueColumn + " FROM " + tableName; using(SqlDataReader dr = cm.ExecuteReader()) { while(dr.Read()) { if(dr.IsDBNull(1)) { Add(dr.GetValue(0).ToString(), string.Empty); } else { Add(dr.GetValue(0).ToString(), dr.GetValue(1).ToString()); } } } } } }
This is pretty straightforward ADO.NET code. We use the database name to retrieve a connection string from the application's configuration file and open the connection:
SqlConnection cn = new SqlConnection(DB(dataBaseName)); SqlCommand cm = new SqlCommand(); cn.Open();
Using this connection, we set up a SqlCommand object to retrieve the name-value columns from the specified table:
cm.Connection = cn; cm.CommandText = "SELECT " + nameColumn + "," + valueColumn + " FROM " + tableName;
Notice that we aren't using parameters in the SQL statement here. This is because the values we're providing are the actual names of columns and the table, not of data values, so there's no way to make them parameter driven.
The remainder of the code simply uses a SqlDataReader to retrieve the data and populate the list.
Note that we aren't using the strongly typed GetString() method to retrieve the data. Rather, we're calling GetValue() and then converting the value to a string . The reason for this is that we can't be sure that the underlying database column type is a string ”it could be numerical or a date, for instance. By using this technique, we ensure that we can read the value and convert it to a string representation regardless of the underlying data type of the database column.
Since we're using the GetValue() method, we have to deal with null values. This code does assume that the key column won't contain nulls and that the key values are unique. It doesn't make that assumption about the values in the data column. They could be null, in which case we convert them to empty string values.
Remember that if we need more sophisticated data access than is provided by SimpleFetch() , we'd just implement our own code rather than calling into the base class.