In the last two articles (Simplifying ADO.NET Code in .NET 6: Part 1 and Part 2), you learned how to retrieve data using a set of wrapper classes around ADO.NET. The code you wrote was about the same lines of code you might write when using the Entity Framework. In this article, you're going to build the appropriate methods to modify data in a table, perform transactions, validate data using data annotations, and add exception handling.

Insert a Product

As the first example of modifying data in a table, let's write code to insert a Product into the SalesLT.Product table within the AdventureWorksLT database. When inserting data into a SQL Server table, you need to be aware of whether the primary key field, or any other field for that matter, has an IDENTITY property assigned to it. If so, when building the INSERT statement to submit, you don't include this field(s) in the INSERT statement.

Specify an Incrementing Field

Just like you use the property names of the entity class to build the SELECT statement, you're also going to use them to build an INSERT statement. However, you need to add an attribute to any property that's an incrementing (IDENTITY) field. Microsoft has provided the [DatabaseGenerated] attribute just for this purpose. Open the Product.cs file and add the [DatabaseGenerated] attribute above the Id property because the ProductID field in the Product table is marked as an IDENTITY field.

[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("ProductID")]
public int Id { get; set; }

Modify the ColumnMapper Class

Just like you've checked for other attributes in the BuildColumnCollection() method and recorded the information in the ColumnWrapper class, you're going to do the same with the [DatabaseGenerated] attribute class. Open the ColumnWrapper.cs file and add a new property named IsAutoIncrementing.

public bool IsAutoIncrementing { get; set; }

Modify BuildColumnCollection() Method

Open the Repository.cs file and locate the BuildColumnCollection() method. Just below where you check for the [Key] attribute, add a check for the [DatabaseGenerated] attribute, as shown in the following code snippet:

// Check for [DatabaseGenerated] attribute Is the column an auto-incrementing column?
DatabaseGeneratedAttribute dg = prop.GetCustomAttribute<DatabaseGeneratedAttribute>();
if (dg != null) 
{
    colMap.IsAutoIncrementing = dg.DatabaseGeneratedOption == DatabaseGeneratedOption.Identity || dg.DatabaseGeneratedOption == DatabaseGeneratedOption.Computed;
}

Add an ExecuteNonQuery() Method

ADO.NET uses the ExecuteNonQuery() method to submit queries that don't return result sets. Add the same named method to the RepositoryBase class. Open the RepositoryBase.cs file and add the method shown below to this class. This method accepts a command object, opens the connection on that command, and calls the ExecuteNonQuery() method to submit the SQL to the database.

public virtual int ExecuteNonQuery(IDbCommand cmd) 
{
    int ret;

    // Open the Connection
    DbContext.CommandObject.Connection.Open();

    // Call the ExecuteNonQuery() method
    ret = DbContext.CommandObject.ExecuteNonQuery();

    return ret;
}

To provide additional flexibility, add an overload of the ExecuteNonQuery() method that accepts a SQL string. You're going to call this method most often from your repository classes.

public virtual int ExecuteNonQuery(string sql) 
{
    // Store the SQL submitted
    SQL = sql;

    // Create Command object with SQL
    DbContext.CreateCommand(SQL);

    // Execute the Query
    return ExecuteNonQuery(DbContext.CommandObject);
}

Add Method to Build an INSERT Statement

You do not want to build an INSERT statement that has the actual values to insert within the string as that isn't secure. Instead, build an INSERT statement that contains the column names, and a set of parameters to match those column names, as shown in the following code snippet. You can then build a collection of SqlParameter objects to insert the value for each parameter.

INSERT INTO SalesLT.Product ([Name], [ProductNumber], [Color]) VALUES (@Name, @ProductNumber, @Color)

Add the new method named BuildInsertStatement() shown in Listing 1 to the RepositoryBase class. This method calls the BuildColumnCollection() method to create the collection of ColumWrapper objects. Call the SetTableAndSchemaName() method so the TableName and SchemaName properties are set. You need these values for the INSERT statement.

Listing 1: Create a method to build an INSERT statement

protected virtual string BuildInsertStatement<TEntity>(TEntity entity) 
{
    StringBuilder sbCol = new(1024);
    StringBuilder sbParam = new(1024);
    string comma = string.Empty;
    Type typ = typeof(TEntity);

    // Build Column Mapping Collection
    Columns = BuildColumnCollection<TEntity>();

    // Set Table and Schema properties
    SetTableAndSchemaName(typ);

    // Build the INSERT statement
    sbCol.Append($"INSERT INTO {SchemaName}.{TableName} (");
    foreach (ColumnMapper item in Columns.Where(c => !c.IsAutoIncrementing)) 
    {
        // Add column
        sbCol.Append($"{comma}[{item.ColumnName}]");
        // Add Parameter
        sbParam.Append($"{comma} {DbContext.ParameterPrefix}{item.ColumnName}");
        comma = ", ";
    }
    sbCol.Append(") VALUES (");
    sbCol.Append(sbParam);
    sbCol.Append(')');

    return sbCol.ToString();
}

Create two StringBuilder objects, one to hold the comma-delimited list of column names, and one to hold the list of comma-delimited parameters. Loop through the columns where the IsAutoIncrementing property is false. Each time through the loop, build the comma-delimited list of column names and parameters. Use the DbContext.ParameterPrefix property to place the at (@) sign before the column name to build the parameter name.

Add Validate() Method Stub

Before inserting or updating the data in the database, it's a good idea to ensure that the data passes your business rules. You're going to learn about validation later in this article, but for now, create a Validate() method that simply returns a true value.

public virtual bool Validate<TEntity>(TEntity entity) 
{
    return true;
}

Add Insert() Method

Add an Insert() method (Listing 2) to the RepositoryBase class. This method calls the Validate() method to verify that the data is correct. If that method returns a true value, the BuildInsertStatement() is called to create the INSERT statement and put that statement into the SQL property. Build a command object with that SQL statement. Next, take the values from the entity class and place the values from each property into the corresponding ColumnWrapper object built in the BuildInsertStatement() method by calling a new method named SetColumnValues(). Once you have those values set, you need to add to the Parameters property of the command object a SqlParameter object for each parameter in the INSERT statement. This is done in a new method named BuildParametersForModification(). Call the ExecuteNonQuery() method to submit the INSERT statement to the database.

Listing 2: The Insert() method builds an INSERT statement and submits the query to the database

public virtual TEntity Insert<TEntity> (TEntity entity) 
{
    if (Validate<TEntity>(entity)) 
    {
        // Build INSERT Statement
        SQL = BuildInsertStatement<TEntity>(entity);

        // Create Command Object with SQL
        DbContext.CreateCommand(SQL);

        // Set Values into ColumnMapper Objects
        SetColumnValues<TEntity>(entity);

        // Build Parameters
        BuildParametersForModification(DbContext.CommandObject, Columns);

        // Submit the Query
        ExecuteNonQuery(DbContext.CommandObject);
    }

    return entity;
}

Set Entity Values into ParameterValue Property

Add the SetColumnValues() method to the RepositoryBase class. In this method, you iterate over the Columns collection built in the BuildInsertStatement() method. Each time through the loop, use reflection to get the value from the entity object and place the value into the ParameterValue property.

protected virtual void SetColumnValues<TEntity>(TEntity entity) 
{
    // Loop through all properties
    foreach (ColumnMapper colMap in Columns) 
    {
        // Set property value
        colMap.ParameterValue = colMap.PropertyInfo.GetValue(entity);
    }
}

Add Method to Build Parameter Objects for Inserting and Updating

As mentioned, you now need to build a collection of SqlParameter objects for each parameter in your INSERT statement. Add a new method to the RepositoryBase class named BuildParametersForModification(). Each time through the loop, create a new SqlParameter object by calling the DbContext.CreateParameter() method. Pass the column name and the value for the INSERT statement to the CreateParameter() method. If the parameter value is null, pass a DBNull.Value so the database receives the correct kind of null value. Add the new parameter to the command objects Parameters collection.

protected virtual void BuildParametersForModification(IDbCommand cmd, List<ColumnMapper> columns) 
{
    // Add parameters for each value passed in
    foreach (ColumnMapper item in columns) 
    {
        var param = DbContext.CreateParameter(item.ColumnName, item.ParameterValue ?? DBNull.Value);
        cmd.Parameters.Add(param);
    }
}

Modify ProductRepository Class

Later in this article, you're going to use data annotations for checking each property in the entity class for correct data. Using data annotations allows you to write generic code to validate the data in all your entity objects. However, you may need some additional validation logic that has to be custom written. For this, add a Validate() method to each of your repository classes. Open the ProductRepository.cs file and add a Validate() method, as shown below:

public virtual bool Validate(Product entity) 
{
    bool ret = base.Validate<Product>(entity);
    // Perform any other validation here
    return ret;
}

Next, add an Insert() method to the ProductRepository class to call the Insert() method in the RepositoryBase class. Creating an Insert() method in your repository classes allows you the flexibility to perform actions either before or after you perform the insert for the table. For example, after inserting data into a table, you might wish to reread the data from the table to retrieve any auto-generated field values.

public virtual Product Insert(Product entity) 
{
    entity = base.Insert<Product>(entity);
    // OPTIONAL: Re-read from database to
    // get any other generated values
    //entity = Find(entity.Id);
    return entity;
}

Try It Out

Open the Program.cs file, create a new Product object, and assign some valid values to the object. Modify the code after the using AdvWorksDbContext db = new(); statement to look like Listing 3. Run the application and see that the new Product was inserted. Notice that the ProductID property is not filled in; however, if you look in the Product table, you'll see that a value was generated. You're going to learn how to retrieve the value from the IDENTITY property in the next section of this article.

Listing 3: Call the Insert() method and report back the entity and the SQL submitted

Product entity = new() 
{
    ProductName = "A New One",
    ProductNumber = "NEW-001",
    Color = "Red",
    StandardCost = 10,
    ListPrice = 20,
    SellStartDate = DateTime.Now
};

entity = db.Products.Insert(entity);

Console.WriteLine("*** Insert a Product ***");
// Display Result
Console.WriteLine($"Product: {entity}");
Console.WriteLine();
Console.WriteLine($"SQL Submitted: {db.Products.SQL}");
Console.WriteLine();

Get IDENTITY from Insert

The ProductID field in the SalesLT.Product table has an IDENTITY property assigned to it. This means that when you insert a new record, a new value is automatically assigned to this field. To retrieve this new value, you must immediately retrieve the identity on the same open connection using either @@IDENTITY or the SCOPE_IDENTITY() function. Let's add this functionality to the base classes.

Modify DatabaseContext Class

Open the DatabaseContext.cs file and add a new using statement at the top of the file.

using System.Reflection;

Add a new abstract method named GetLastAutoIncrement(). You need to override this method in your database-specific context class as each database engine retrieves their auto-incrementing fields in a different way.

public abstract void GetLastAutoIncrement<TEntity>(IDbCommand cmd, TEntity entity, PropertyInfo prop);

Modify SqlServerDatabaseContext Class

Open the SqlServerDatabaseContext.cs file and add a new using statement at the top of this file.

using System.Reflection;

Override the GetLastAutoIncrement() method you just added to the DatabaseContext class. If you're not using any triggers on your database, you can use the @@IDENTITY as I'm doing in the code shown below. If you're performing your inserts in a stored procedure, return the identity value using an OUTPUT parameter.

public override void GetLastAutoIncrement<TEntity>(IDbCommand cmd, TEntity entity, PropertyInfo prop) 
{
    cmd.CommandText = "SELECT CONVERT(INT, @@IDENTITY);";

    cmd.CommandType = CommandType.Text;
    int? value = (int?)cmd.ExecuteScalar();
    if (value.HasValue) 
    {
        prop.SetValue(entity, value, null);
    }
}

Add a GetLastAutoIncrement() Method to RepositoryBase

Open the RepositoryBase.cs file and add a GetLastAutoIncrement() method to this class. This method checks to see if there are any auto-incrementing fields for the table. If there are, it finds that field and passes the entity class and the PropertyInfo object for that property to the GetLastAutoIncrement() method to have that field set with the value returned from the @@IDENTITY function.

protected virtual void GetLastAutoIncrement<TEntity>(IDbCommand cmd, TEntity entity) 
{
    if (Columns.Where(c => c.IsAutoIncrementing).Any()) 
    {
        ColumnMapper colMap = Columns.Find(c => c.IsAutoIncrementing);
        DbContext.GetLastAutoIncrement(cmd, entity, colMap.PropertyInfo);
    }
}

Modify the Insert() Method

Just after the call to the ExecuteNonQuery() in the Insert() method, add the call to the GetLastAutoIncrement() method to retrieve the value and put it into the primary key field.

// Get IDENTITY if needed
GetLastAutoIncrement<TEntity>(DbContext.CommandObject, entity);

Try It Out

Open the Program.cs file and change the values for the new product entity to insert so it doesn't conflict with the record you previously inserted.

Product entity = new() 
{
    ProductName = "Identity Test",
    ProductNumber = "IDN-001",
    Color = "Blue",
    StandardCost = 20,
    ListPrice = 40,
    SellStartDate = DateTime.Now
};

Run the application and you should see the new ProductID show up in the console window.

Update a Product

Now that you know how to insert a new product, let's look at updating records through generic classes. The first step is to add a method to build an UPDATE statement. This method is very similar to the method you wrote to build an INSERT statement. Open the RepositoryBase.cs file and add a new method named BuildUpdateStatement(), as shown in Listing 4.

Listing 4: Add a method to build an UPDATE statement

protected virtual string BuildUpdateStatement<TEntity>(TEntity entity) 
{
    StringBuilder sb = new(2048);
    string comma = string.Empty;
    string and = string.Empty;
    Type typ = typeof(TEntity);

    // Build Column Mapping Collection
    Columns = BuildColumnCollection<TEntity>();

    // Set Table and Schema properties
    SetTableAndSchemaName(typ);

    // Build the UPDATE statement
    sb.Append($"UPDATE {SchemaName}.{TableName} SET ");
    foreach (ColumnMapper item in Columns.Where(c => !c.IsAutoIncrementing)) 
    {
        // Add column
        sb.Append($"{comma}[{item.ColumnName}] = {DbContext.ParameterPrefix}{item.ColumnName}  ");
        comma = ", ";
    }
    sb.Append(" WHERE ");
    foreach (ColumnMapper item in Columns.Where(c => c.IsKeyField)) 
    {
        // Add WHERE Clause
        sb.Append($"{and}{item.ColumnName} = {DbContext.ParameterPrefix}{item.ColumnName}");
        and = " AND ";
    }

    return sb.ToString();
}

The UPDATE statement you're going to build looks something like the following SQL:

UPDATE SalesLT.Product SET
  [Name] = @Name,
  [ProductNumber] = @ProductNumber,
  [Color] = @Color
WHERE ProductID = @ProductID

Build the collection of columns to update from your entity class by calling the BuildColumnCollection() method. Next, set the table and schema name properties. Loop through the columns collection to build the list of columns and parameters. Then loop through the set of columns where the IsKeyField property is equal to true.

Add Update() Method

The next method to add is the Update() method to which you pass the entity object with the values to be updated, as shown in Listing 5. Call the Validate() method to ensure that the data is correct prior to attempting the update in the database table. Call the BuildUpdateStatement() method to build the SQL statement. Create the command object and pass it the SQL statement. Set the column values by calling the SetColumnValues() method just like you did in the Insert() method. Call the BuildParametersForModification() method to create the collection of SqlParameter objects and add them to the command object. Finally, execute the UPDATE statement by calling the ExecuteNonQuery() method.

Listing 5: The Update() method builds the UPDATE statement and submits it to the database

public virtual TEntity Update<TEntity> (TEntity entity) 
{
    if (Validate<TEntity>(entity)) 
    {
        // Build UPDATE Statement
        SQL = BuildUpdateStatement<TEntity>(entity);

        // Create Command Object with SQL
        DbContext.CreateCommand(SQL);

        // Set Values into ColumnMapper Objects
        SetColumnValues<TEntity>(entity);

        // Build Parameters
        BuildParametersForModification(
          DbContext.CommandObject, Columns);

        // Submit the Query
        ExecuteNonQuery(DbContext.CommandObject);
    }

    return entity;
}

Modify the ProductRepository Class

Just like you did with the Insert() method, create an Update() method on your repository classes so you can perform any operations either before or after the update. Open the ProductRepository.cs file and add an Update() method, as shown in the code snippet below.

public virtual Product Update(Product entity) 
{
    entity = base.Update<Product>(entity);
    // OPTIONAL: Re-read from database to get any other generated values
    //entity = Find(entity.Id);
    return entity;
}

Try It Out

Open the Program.cs file and we'll build a SQL statement to retrieve the largest value from the ProductID field, as that will be the last product record you added (Listing 6). Call the ExecuteScalar() method to get that product ID. Call the Find() method on the Products property in the database context object. Now that you have the entity object, modify a few properties to different values so you can see that the changes have been made in the products table. Call the Update() method to submit those changes to the database. Run the application and you should see the entity object and the SQL statement submitted. You should look at the data in your SQL Server to verify everything did get changed.

Listing 6: Update a record by setting values on an entity object, then invoking the Update() method on the repository class

string sql = "SELECT Max(ProductID) FROM SalesLT.Product";
int id = (int)db.Database.ExecuteScalar(sql);

Product entity = db.Products.Find(id);
entity.Color += "CHANGED";
entity.StandardCost = 99;
entity.ListPrice = 299;

entity = db.Products.Update(entity);

Console.WriteLine("*** Update Product Data ***");
Console.WriteLine($"Product: {entity}");
Console.WriteLine();
Console.WriteLine($"SQL Submitted: {db.Products.SQL}");
Console.WriteLine();

Delete a Product

The next functionality to add to the RepositoryBase class is to delete a specific record in a table. Open the RepositoryBase.cs file and add a new method named BuildDeleteStatement(), as shown in Listing 7. This code should look familiar by now as it's very similar to the BuildUpdateStatement() method. Build the collection of columns, set the table and schema name properties, then build the DELETE statement with the appropriate WHERE clause.

Listing 7: Add a method to build the DELETE statement

protected virtual string BuildDeleteStatement<TEntity>(TEntity entity) 
{
    StringBuilder sb = new(2048);
    string and = string.Empty;
    Type typ = typeof(TEntity);

    // Build Column Mapping Collection
    Columns = BuildColumnCollection<TEntity>();

    // Set Table and Schema properties
    SetTableAndSchemaName(typ);

    // Build the DELETE statement
    sb.Append($"DELETE FROM {SchemaName}.{TableName}");
    sb.Append(" WHERE ");
    foreach (ColumnMapper item in Columns.Where(c => c.IsKeyField)) 
    {
        // Add WHERE Clause
        sb.Append($"{and}{item.ColumnName} = {DbContext.ParameterPrefix}{item.ColumnName}  ");
        and = " AND ";
    }

    return sb.ToString();
}

Add Delete() Method

Add a new Delete() method to the RepositoryBase class to match the code you see in Listing 8. This method calls the BuildDeleteStatement() method to create the SQL statement to submit. Call the SetColumnValues() method to add the entity object values into the ColumnWrapper object. You only need the primary key value, but because this method is already written, you can just use this method to set the value. Only pass those columns where the IsKeyField property is true to the BuildWhereClauseParameters() method to just add the primary key field(s) as a parameter to the command object. Call the ExecuteNonQuery() method to execute the UPDATE statement on the database table.

Listing 8: The Delete() method builds the DELETE statement and submits it to the database

public virtual bool Delete<TEntity> (TEntity entity) 
{
    bool ret = true;

    // Build DELETE Statement
    SQL = BuildDeleteStatement<TEntity>(entity);

    // Create Command Object with SQL
    DbContext.CreateCommand(SQL);

    // Set Values into ColumnMapper Objects
    SetColumnValues<TEntity>(entity);

    // Build Parameters
    BuildWhereClauseParameters(DbContext.CommandObject, Columns.Where(c => c.IsKeyField).ToList());

    // Submit the Query
    ExecuteNonQuery(DbContext.CommandObject);

    return ret;
}

Add Delete() Methods in ProductRepository Class

Open the ProductRepository.cs file and add a couple of Delete() methods to this class. I like having options, so I have one method that accepts an integer ID of the primary key to delete, and the other accepts a complete Product object. The first option is used when writing web applications, and the other is typically used when writing desktop applications with WPF.

public virtual bool Delete(int id) 
{
    Product product = base.Find<Product>(id);
    return Delete(product);
}

public virtual bool Delete(Product entity) 
{
    return base.Delete<Product>(entity);
}

Try It Out

Open the Program.cs file and modify the code you used to update a product to look like the following snippet:

string sql = "SELECT Max(ProductID) FROM SalesLT.Product";
int id = (int)db.Database.ExecuteScalar(sql);

Product entity = db.Products.Find(id);

entity = db.Products.Delete(entity);

Console.WriteLine("*** Delete a Product ***");
// REST OF THE CODE HERE

Notice that most of the code is the same; you just eliminate the code to make changes to the entity variable, and call the Delete() method instead of the Update() method.

You can also remove the line that invokes the Find() method and pass the id variable to the other overloaded Delete() method. Which method you choose to call is completely up to you and your circumstances for your application.

// Delete by Primary Key
db.Products.Delete(id);

Run the application and you should see the product object deleted and the SQL statement. Check the SalesLT.Product table in your SQL Server database to ensure that the record was deleted.

Transactions

Now that you've built standard CRUD functionality into the generic ADO.NET classes, let's look at creating a transaction. A transaction is a set of data modifications statements that all must either complete successfully, or all of them must be rolled back. Add a CustomerRepository class to the RepositoryClasses folder and add the code shown in Listing 9.

Listing 9: Create a CustomerRepository class for working with the Customer table

#nullable disable

using AdoNetWrapper.Common;
using AdoNetWrapperSamples.EntityClasses;
using AdoNetWrapperSamples.Models;

namespace AdoNetWrapperSamples.RepositoryClasses;

public class CustomerRepository : RepositoryBase
{
    public CustomerRepository(AdvWorksDbContext context) : base(context) 
    {
    }

    public virtual List<Customer> Search() 
    {
        return base.Search<Customer>();
    }

    public virtual Customer Find(int id) 
    {
        return base.Find<Customer>(id);
    }

    public virtual bool Validate(Customer entity) 
    {
        bool ret = base.Validate<Customer>(entity);

        // Perform any other validation here

        return ret;
    }

    public virtual Customer Insert(Customer entity) 
    {
        entity = base.Insert<Customer>(entity);

        // OPTIONAL: Re-read from database to
        // get any other generated values
        //entity = Find(entity.CustomerID);

        return entity;
    }

    public virtual Customer Update(Customer entity) 
    {
        entity = base.Update<Customer>(entity);

        // OPTIONAL: Re-read from database to
        // get any other generated values
        //entity = Find(entity.Id);

        return entity;
    }

    public virtual bool Delete(int id) 
    {
        Customer Customer = base.Find<Customer>(id);

        return Delete(Customer);
    }

    public virtual bool Delete(Customer entity) 
    {
        return base.Delete<Customer>(entity);
    }
}

The code in Listing 9 is representative of the code needed to provide CRUD logic for each table in your database. Because you've built so much generic logic into the common base classes, your repository classes are easy to read and understand.

Modify Adventure Works Database Context Class

To expose the CRUD functionality for the Customer table, add a Customers property to AdvWorksDbContext class.

public CustomerRepository Customers { get; set; }

Modify the constructor of the AdvWorksDbContext class to create a new instance of the repository class and pass to the constructor the instance of the AdvWorksDbContext class.

Customers = new(this);

Add Functionality to Database Context Class for Transactions

Open DatabaseContext.cs file and add three new properties to the class. Because a transaction must be completed on a single connection, expose a connection object from the database context. Also, a transaction object should be exposed so you can commit or rollback the set of statements in the transactions. Add a Boolean property to specify whether a transaction is currently active on the connection. The three properties to add are shown in the following code snippet:

public bool IsInTransaction { get; set; }
public IDbConnection ConnectionObject { get; set; }
public IDbTransaction TransactionObject { get; set; }

Add a method named BeginTransaction() to create the connection object, open the connection, and begin a transaction by calling the BeginTransaction() method on the connection object. Set the IsInTransaction property to a true value to signify a transaction has been started.

public virtual void BeginTransaction() 
{
    // Create Connection
    ConnectionObject = CreateConnection();
    
    // Open Connection
    ConnectionObject.Open();
    
    // Create Transaction
    TransactionObject = ConnectionObject.BeginTransaction();
    IsInTransaction = true;
}

Add a method named CommitTransaction() to the DatabaseContext class to check whether the ConnectionObject property is not null and is also open. If it is and the TransactionObject property is not null, call the Commit() method on the transaction object, close the connection, and set the IsInTransaction property to false.

public virtual void CommitTransaction() 
{
    if (ConnectionObject != null && ConnectionObject.State == ConnectionState.Open) 
    {
        if (TransactionObject != null) 
        {
            TransactionObject.Commit();
            ConnectionObject.Close();
            IsInTransaction = false;
        }
    }
}

Add a method named RollbackTransaction() that also checks if the ConnectionObject property is not null and is open. If it is, and the TransactionObject property is not null, call the Rollback() method on the transaction object, close the connection, and set the IsInTransaction property to false.

public virtual void RollbackTransaction() 
{
    if (ConnectionObject != null && ConnectionObject.State == ConnectionState.Open) 
    {
        if (TransactionObject != null) 
        {
            TransactionObject.Rollback();
            ConnectionObject.Close();
            IsInTransaction = false;
        }
    }
}

Because you added two new properties to this class, add code in the Dispose() method to release these objects if they are not null, as shown in the following code snippet. These lines of code should go at the very end of the Dispose() method.

// Close/Dispose of Transaction object
if (TransactionObject != null) 
{
    TransactionObject.Dispose();
}

// Close/Dispose of Connection object
if (ConnectionObject != null) {
    ConnectionObject.Close();
    ConnectionObject.Dispose();
}

Modify SQL Server Database Context Class

There are a few key methods you must now modify to take advantage of transactions. First, modify the CreateConnection() method in the SqlServerDatabaseContext class and make this method look like the following code snippet:

public override SqlConnection CreateConnection(string connectString) 
{
    if (IsInTransaction) 
    {
        if (ConnectionObject == null) 
        {
            ConnectionObject = new SqlConnection(connectString);
        }
        return (SqlConnection)ConnectionObject;
    }
    else 
    {
        return new SqlConnection(connectString);
    }
}

Next, modify the CreateCommand() method to check whether a transaction is started, and if so, set the TransactionObject into the CommandObject.Transaction property.

public override SqlCommand CreateCommand(IDbConnection cnn, string sql) 
{
    CommandObject = new SqlCommand(sql, (SqlConnection)cnn);
    CommandObject.CommandType = CommandType.Text;

    if (IsInTransaction) 
    {
        CommandObject.Transaction = TransactionObject;
    }
    return (SqlCommand)CommandObject;
}

Modify Repository Base Class

The last place to modify is within the ExecuteNonQuery() method. You want to check to see if the connection object is open or not. If it isn't open, you need to open it.

public virtual int ExecuteNonQuery(IDbCommand cmd) 
{
    int ret;

    // Open the Connection
    if (DbContext.CommandObject.Connection.State != ConnectionState.Open) 
    {
        DbContext.CommandObject.Connection.Open();
    }

    // Call the ExecuteNonQuery() method
    ret = DbContext.CommandObject.ExecuteNonQuery();

    return ret;
}

Modify Customer Class

When you created the Customer class in the first article of this series, you just created enough properties to show retrieving some data. As you're now going to be attempting to insert data into the Customer table, you need to add some new properties, and mark the CustomerID property as an IDENTITY field. Open the Customer.cs file and above the CustomerID property, add the [DatabaseGenerated] attribute.

public partial class Customer 
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int CustomerID { get; set; }

    // REST OF THE CODE
}

Toward the bottom of the class, add a few more properties that are required fields in the Customer table. These properties are PasswordHash, PasswordSalt, Rowguid, and ModifiedDate.

public string PasswordHash { get; set; }
public string PasswordSalt { get; set; }
public Guid Rowguid { get; set; }
public DateTime ModifiedDate { get; set; }

Add Transaction Code to View Model Class

Instead of writing a bunch of code in the Program.cs file, let's add the transaction functionality to the ProductCustomerViewModel class. Open the ProductCustomerViewModel.cs file and add a new method named InsertProductAndCustomer(), as shown in Listing 10. After creating an instance of the AdvWorksDbContext class, call the BeginTransaction() method to specify you are working on performing a series of data modifications. Specifically, you are inserting a new product and a new customer record. Wrap up this code within a try...catch so if anything goes wrong, within the catch block, you can rollback the transaction. However, if everything is successful, you call the CommitTransaction() method on the database context variable.

Listing 10: Create the code to perform a transaction and a commit or rollback

public bool InsertProductAndCustomer() 
{
    bool ret = true;

    using AdvWorksDbContext db = new(ConnectString);

    try 
    {
        db.BeginTransaction();

        Product product = new() 
        {
            ProductName = "Product to Insert",
            ProductNumber = "INS-001",
            Color = "Green",
            StandardCost = 5,
            ListPrice = 25,
            SellStartDate = DateTime.Now
        };

        product = db.Products.Insert(product);

        Customer customer = new() 
        {
            CompanyName = "New Customer",
            FirstName = "John",
            LastName = "Doe",
            Title = "Mr.",
            PasswordHash = "Qa3aMCxNq9GZSUxcTM=",
            PasswordSalt = "Ls05W4g=",
            Rowguid = Guid.NewGuid(),
            ModifiedDate = DateTime.Now
        };

        customer = db.Customers.Insert(customer);

        // Commit the Transaction
        db.CommitTransaction();
    }

    catch (Exception ex) 
    {
        ret = false;
        Console.Write(ex.ToString());
        // Rollback the Transaction
        db.RollbackTransaction();
    }

    return ret;
}

Try It Out

Open the Program.cs file and add the code shown below to call the InsertProductAndCustomer() method to attempt the transaction.

ProductCustomerViewModel vm = new(ConnectString);

bool isSuccess = vm.InsertProductAndCustomer();

Console.WriteLine("*** Perform a Transaction ***");
// Display Result
if (isSuccess) 
{
    Console.WriteLine("Transaction Successful");
}
else 
{
    Console.WriteLine("Transaction WAS NOT Successful");
}

Run the application and check the two tables to ensure that both records were inserted correctly.

Validate a Product

In this article, you always specified correct data to insert a product and/or customer. However, when you're asking a user to enter data, all sorts of things can be entered incorrectly. Therefore, good data validation is a must. The goal here is to check the data in each property using a combination of data annotations, and the custom code you write in each repository class. If you find bad data, create an object to report which property is in error as well as the error message to display to the user.

Modify Product Class

Let's add some data annotations to a few of the properties in the Product class to ensure that proper data is entered. Open the Product.cs file and add the [Display] and [Required] attributes to the Id property. The [Display] attribute is typically used for displaying a label on a web page when using MVC, but it's also used as the label to add to any of the data annotations that use placeholders such as the [Required] attribute. In the [Required] attribute, if you specify a {0} placeholder, then the value in the Name property of the [Display] attribute is inserted into that location.

[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("ProductID")]
[Display(Name = "Product Id")]
[Required(ErrorMessage = "{0} must be filled in.")]
public int Id { get; set; }

Modify the ProductName property to add [Display], [Required], and [StringLength] attributes. The [Required] attribute is self-explanatory; the property must be filled in to be valid. The [StringLength] attribute allows you to specify a minimum and maximum length the string value must fall within.

[Column("Name")]
[Display(Name = "Product Name")]
[Required(ErrorMessage = "{0} must be filled in.")]
[StringLength(50, MinimumLength = 3, ErrorMessage = "{0} must be between {2} and {1} characters long.")]
public string ProductName { get; set; }

Modify the ProductNumber property and add [Display], [Required], [StringLength], and [MaxLength] attributes. You don't need to specify both [StringLength] and [MaxLength] attributes, but I want to show you the [MaxLength] attribute. Use [StringLength] attribute when you need both a minimum and maximum lengths, but use [MaxLength] when you only need a maximum length.

[Display(Name = "Product Number")]
[Required(ErrorMessage = "{0} must be filled in.")]
[StringLength(25, MinimumLength = 2, ErrorMessage = "{0} must be between {2} and {1} characters long.")]
[MaxLength(25)]
public string ProductNumber { get; set; }

Modify the Color property and add the [Display] and [StringLength] attributes.

[Display(Name = "Color")]
[StringLength(15, MinimumLength = 3, ErrorMessage = "{0} must be between {2} and {1} characters long.")]
public string Color { get; set; }

Modify the StandardCost property and add the [Display], [Required], [DataType], and [Range] attributes. The [DataType] attribute is generally used for formatting on MVC or other applications that can look at these attributes. The [Range] attribute is useful if you want to enforce the range a numeric or date field can fall within.

[Display(Name = "Standard Cost")]
[Required(ErrorMessage = "{0} must be filled in.")]
[DataType(DataType.Currency)]
[Range(0.01, 9999999, ErrorMessage =  "{0} must be between {1:c} and {2:c}")]
public decimal StandardCost { get; set; }

The ListPrice property should have the same attributes you added for the StandardCost property.

[Display(Name = "List Price")]
[Required(ErrorMessage = "{0} must be filled in.")]
[DataType(DataType.Currency)]
[Range(0.01, 9999999, ErrorMessage = "{0} must be between {1:c} and {2:c}")]
public decimal ListPrice { get; set; }

Add Class to Hold a Validation Message

When you wish to report the error message you gather from the data annotation attributes, you're going to want the property name that's in error, as well as the error message itself. Right mouse-click on the Common folder and add a new class named ValidationMessage to hold this information.

#nullable disable
namespace AdoNetWrapper.Common;
public class ValidationMessage 
{
    public string PropertyName { get; set; }
    public string Message { get; set; }
    public override string ToString() 
    {
        return $"{PropertyName} - {Message}";
    }
}

Modify RepositoryBase Class

Open the RepositoryBase.cs file and add a new property named ValidationMessages to hold a collection of the ValidationMessage objects.

public List<ValidationMessage> ValidationMessages { get; set; }

Locate the Init() method add code to initialize the ValidationMessages property to a new instance.

ValidationMessages = new();

Validate the Data Annotations

Locate the Validate() method stub you wrote earlier and modify the code to look like Listing 11. The first thing to do is to check whether the entity to be validated is not null. If it isn't, create an instance of a ValidationContext object passing in the entity variable. Create an instance of list of ValidationResult objects, as this collection is filled in when calling the TryValidateObject() method.

Listing 11: The Validate() method uses the ValidationContext class to validate the data annotations

public virtual bool Validate<TEntity> (TEntity entity) 
{
    string propName;
    ValidationMessages.Clear();

    if (entity != null) 
    {
        ValidationContext context = new(entity);
        List<ValidationResult> results = new();

        if (!Validator.TryValidateObject(entity, context, results, true)) 
        {
            foreach (ValidationResult item in results) 
            {
                propName = string.Empty;
                if (item.MemberNames.Any()) 
                {
                    propName = item.MemberNames.ToList()[0];
                }
                ValidationMessages.Add(new() 
                {
                    Message = item.ErrorMessage,
                    PropertyName = propName
                });
            }
        }
    }

    return ValidationMessages.Count == 0;
}

The TryValidateObject() method is responsible for checking all data annotations attached to each property in the entity object. If any validations fail, the appropriate error message along with the property name is returned in the results variable. Loop through the results collection and add a new ValidationMessage object to the ValidationMessages property. The Message property is filled in with the ErrorMessage property from the current ValidationResult item. The property name is retrieved from the first element of the MemberNames property on the ValidationResult item. It's possible for a data annotation to have two properties to which it applies, but for most simple properties, you only need to grab the first property name.

Try It Out

Open the Program.cs and replace the code to look like Listing 12. A Product object is created with many of the properties set to invalid data so you can see what the error messages look like. Call the new Validate() method to fill in the ValidationMessages property with the properties that have invalid data. Run the application and you should see several validation messages appear in the console window.

Listing 12: Create an invalid object and call the Validate() method

Product entity = new() 
{
    ProductName = "a",
    ProductNumber = "NEW-001-A-REALLY-LONG-PRODUCT-NUMBER-TO-TEST-VALIDATION",
    Color = "Red-A-REALLY-LONG-COLOR",
    StandardCost = -1,
    ListPrice = -2,
    SellStartDate = DateTime.Now
};

bool ret = db.Products.Validate(entity);

Console.WriteLine("*** Validation Sample ***");

// Display Result
if (ret) 
{
    Console.WriteLine($"Product Validated");
}
else 
{
    Console.WriteLine($"Product NOT Validated");
    // Display Validation Messages
    foreach (var item in db.Products.ValidationMessages) 
    {
        Console.WriteLine("   " + item);
    }
}
Console.WriteLine();

Add Custom Validations

Microsoft has a limited number of data annotations that you can apply to your properties. You have two choices: You can create your own attributes to perform data checking, or you can write custom validations in the Validate() method of your repository classes. In this article, I'm going to show you how to write custom validations.

Add Validate() Method

Open the ProductRepository.cs file and modify the Validate() method (Listing 13) to call the generic Validate() method in the base class, and then perform your custom validations. Check the value in the StandardCost property to see if it's greater than the value in the ListPrice property. If it is, add the appropriate ValidationMessage object to the ValidationMessages collection. Next, check whether the value in the SellStartDate property is greater than the value in the SellEndDate property. If this condition is true, add a new ValidationMessage object to the ValidationMessages collection. You can continue this process with any other validation you wish to perform.

Listing 13: Use the Validate() method in the ProductRepository to add custom validations

public virtual bool Validate (Product entity) 
{
    base.Validate<Product>( entity);

    // Perform any other validation here
    if (entity.StandardCost > entity.ListPrice) 
    {
        ValidationMessages.Add(new() 
        {
            PropertyName = "StandardCost",
            Message = "Cost must be Less Than the List Price"
        });
    }
    if (entity.SellStartDate > entity.SellEndDate) 
    {
        ValidationMessages.Add(new() 
        {
            PropertyName = "SellStartDate",
            Message = "Selling Start Date must be Less Than the Selling End Date"
        });
    }

    return ValidationMessages.Count == 0;
}

Try It Out

Open the Program.cs file and change the StandardCost property to 10 and the ListPrice property to 5, which violates the custom code you wrote in the Validate() method.

StandardCost = 10,
ListPrice = 5,

Also, set the SellEndDate property and set it to 5 days in the past, which will also violate the custom code you wrote.

SellEndDate = DateTime.Now.AddDays(-5);

Run the application and you should see the additional two custom validation messages appear in the console window.

Exception Handling

When interacting with a database server, there are a multitude of things that can go wrong. The network may be down, the server may be down, you may have a syntax error in the SQL statement you're submitting, someone might have changed a field name in the table you're trying to access, etc. Although the error messages you get from .NET are sometimes very cryptic, the errors you get from a SQL Server are generally very descriptive.

It's very important when working with ADO.NET that you catch the errors as close to where they are generated as possible. If you rely on .NET to throw the exception up your call stack, you're missing a lot of important information. If you catch the error where you're submitting the command object, you have a lot of information that you can gather for logging purposes. For example, you have the SQL statement, the connection string, the command parameters, all the values you set in those command parameters, and you have the complete call stack of how you got to the call that failed. Having all this information can help you figure out what the error is much quicker.

If you rely on .NET to throw the exception up your call stack, you're missing a lot of important information.

Add a DatabaseException Class

Just like you did with the DatabaseContext and the SqlServerDatabaseContext classes, you're going to build a generic DatabaseException class and a SqlServerDatabaseException class. Most of the code goes into the generic DatabaseException class, but there are few methods you are going to override in the SqlServerDatabaseException class to handle SQL Server-specific exceptions. Right mouse-click on the Common folder and create a new class named DatabaseException, as shown in Listing 14.

Listing 14: The DatabaseException class gathers data from various sources to give us a lot of information about the exception

#nullable disable

using System.Data;
using System.Text;

namespace AdoNetWrapper.Exceptions.Common;

public partial class DatabaseException : Exception 
{
    public DatabaseException() : base() { }
    public DatabaseException(string message): base (message) { }
    public DatabaseException(string message, Exception innerException) : base(message, innerException) { }
    
    public DatabaseException(string message, Exception innerException, IDbCommand cmd) : base(message, innerException) 
    {
        CommandObject = cmd;
    }

    public IDbCommand CommandObject { get; set; }
    public string ExceptionType { get; set; }
    public string InnerExceptions { get; set; }
    public string SQL { get; set; }
    public string ConnectionString { get; set; }
    public string ParameterValues { get; set; }
    public bool IsInTransaction { get; set; }

    public virtual void SetAllExceptionInfo() 
    {
        return sb.ToString();
    }

    public virtual string GetCommandParameters() 
    {
        StringBuilder sb = new(1024);
        return sb.ToString();
    }

    protected virtual string HideLoginInfo(string connectString) 
    {
        return "****";
    }

    protected virtual string GetDatabaseSpecificError(Exception ex) 
    {
        return string.Empty;
    }

    protected virtual string GetInnerExceptionInfo() 
    {
        StringBuilder sb = new(1024);
        return sb.ToString();
    }

    public override string ToString() 
    {
        StringBuilder sb = new(2048);
        return sb.ToString();
    }
}

When you inherit from an Exception class, you must provide the appropriate constructors to override the base class and call the matching constructor base class. I've added one additional constructor that accepts a command object. The database-specific error information is gathered from the command object, so it's important to pass this in when a database exception is thrown. The public properties are going to be filled in by calling the various methods that are stubbed out in this listing and by retrieving information from the command object.

Add HandleException() Method to DatabaseContext Class

Before you build the rest of the methods in the DatabaseException class, let's first add a method that can be called to throw a DatabaseException object. Open the DatabaseContext.cs file and add an abstract HandleException() method.

public abstract void HandleException(Exception ex);

Add HandleException() Method to SqlServerDatabaseContext Class

Open the SqlServerDatabaseContext and override the HandleException() method with one to throw an instance of a SqlServerDatabaseException class. To the constructor of this object, pass in the exception message and the exception object itself. And, most importantly, pass in the CommandObject, as that's where you can retrieve the connection string, the SQL, and any parameters that might have caused the error.

public override void HandleException(Exception ex) 
{
    throw new SqlServerDatabaseException(ex.Message, ex, (SqlCommand)CommandObject);
}

Call HandleException() Method

You now need to modify just a few key areas within the SqlServerDatabaseContext class. Locate the CreateDataReader() method and wrap a try...catch block around the code that opens the connection and executes the reader. Within the catch block, call the HandleException() method.

public override SqlDataReader CreateDataReader(IDbCommand cmd, CommandBehavior cmdBehavior = CommandBehavior.CloseConnection) 
{
    try 
    {
        // Open Connection
        cmd.Connection.Open();
        
        // Create DataReader
        DataReaderObject = cmd.ExecuteReader(cmdBehavior);
    }
    catch (Exception ex) 
    {
        HandleException(ex);
    }
    return (SqlDataReader)DataReaderObject;
}

Next, locate the GetLastAutoIncrement() method and wrap a try...catch around the call to the ExecuteScalar() method, as shown in the following code snippet. Within the catch block, call the HandleException() method.

public override void GetLastAutoIncrement<TEntity>(IDbCommand cmd, TEntity entity, PropertyInfo prop) 
{
    cmd.CommandText = "SELECT CONVERT(INT, @@IDENTITY);";
    cmd.CommandType = CommandType.Text;
    try
    {
        int? value = (int?)cmd.ExecuteScalar();
        if (value.HasValue) 
        {
            prop.SetValue(entity, value, null);
        }
    }
    catch (Exception ex) 
    {
        HandleException(ex);
    }
}

There are couple of more methods to modify handle exceptions. Open the RepositoryBase.cs file and locate the ExecuteScalar() method and wrap a try...catch block around the code that opens the connection and calls the ExecuteScalar() method. Call the DbContext.HandleException() method within the catch block.

public virtual object ExecuteScalar(IDbCommand cmd) 
{
    object ret = null;
    try 
    {
        // Open the Connection
        DbContext.CommandObject.Connection.Open();

        // Call the ExecuteScalar() method
        ret = DbContext.CommandObject.ExecuteScalar();
    }
    catch (Exception ex) 
    {
        DbContext.HandleException(ex);
    }
    return ret;
}

Also wrap a try...catch around the code in the ExecuteNonQuery() method as shown in the code snippet below. Again, make sure you call the HandleException() method within the catch block so it can gather the command object from this call.

public virtual int ExecuteNonQuery(IDbCommand cmd) 
{
    int ret = 0;
    try 
    {
        // Open the Connection
        DbContext.CommandObject.Connection.Open();

        // Call the ExecuteNonQuery() method
        ret = DbContext.CommandObject.ExecuteNonQuery();
    }
    catch (Exception ex) 
    {
        DbContext.HandleException(ex);
    }
    return ret;
}

Catching the DatabaseException Object

From within your application, you should always wrap up code that makes database calls in a try...catch block.

try 
{
    // ERROR CAUSING CODE
    List<Product> list = db.Database.Search<Product>(cmd);
}
catch (DatabaseException ex) 
{
    string msg = ex.ToString();
    Console.WriteLine(msg);
}

In the catch block, receive a DatabaseException object and call the ToString() method on that object to get back all of the exception information. In the previous code snippet, the ToString() method is called on the DatabaseException object and all the data will come back to you, as shown in Figure 1.

Figure 1: The exception handler puts out a lot of useful information.
Figure 1: The exception handler puts out a lot of useful information.

Override the ToString() Method

To build the data shown in Figure 1, you need to build a string of all the exception information you can gather. Open up the DatabaseException.cs file and modify the ToString() method to look like the code in Listing 15. The first thing this method does is call the SetAllExceptionInfo() method. This method calls all the other methods in the exception classes to set all the public properties in this class. These properties are then built into a large string to be returned from this method. Once you have this exception string, log it in a data store so you can look at it later.

Listing 15: Override the ToString() method to return all exception information as a single string

public override string ToString() 
{
    StringBuilder sb = new(2048);
    string exDate = DateTime.Now.ToString();
    string dashes = new('-', 52);
    string equals = new('=', 25);

    // Set all exception information properties
    SetAllExceptionInfo();

    sb.AppendLine(dashes);
    sb.AppendLine($"* BEGIN: Exception Generated on {exDate}");
    sb.AppendLine(dashes);
    sb.AppendLine($"Message(s): {Message}");
    sb.AppendLine($"Exception Type: {ExceptionType}");
    sb.AppendLine($"Connection String: {ConnectionString}");
    sb.AppendLine($"SQL: {SQL}");
    sb.AppendLine($"Command Parameters: {ParameterValues}");
    sb.AppendLine($"Is In Transaction?: {IsInTransaction}");
    sb.AppendLine($"Machine Name: {Environment.MachineName}");
    sb.AppendLine($"Inner Exceptions: {InnerExceptions}");
    if (!string.IsNullOrEmpty(StackTrace)) 
    {
        sb.AppendLine($"{equals}");
        sb.AppendLine($"* BEGIN: Stack Trace Info");
        sb.AppendLine($"{equals}");
        sb.AppendLine(StackTrace);
    }
    sb.AppendLine(dashes);
    sb.AppendLine($"* END: Exception Generated on {exDate}");
    sb.AppendLine(dashes);

    return sb.ToString();
}

Set All Exception Information Method

The method named SetAllExceptionInfo(), Listing 16, is responsible for setting the various public properties in the DatabaseException class. This method first initializes all public properties to an empty string. It then starts filling in each property with information from the command, connection, and transaction objects. It also calls the HideLoginInfo() method to hide any user ID and/or passwords in the connection string. You do not want that information to be logged anywhere. It calls the GetCommandParameters() method to create a string with a list of any parameter names and the values submitted with the SQL. Finally, it calls the GetInnerExceptionInfo() method to look at the current exception and see if there is more information in the InnerException property that should be gathered.

Listing 16: Call SetExceptionInfo() method to gather all the exception information

public virtual void SetAllExceptionInfo() 
{
    ExceptionType = string.Empty;
    ConnectionString = string.Empty;
    InnerExceptions = string.Empty;
    SQL = string.Empty;
    ParameterValues = string.Empty;

    ExceptionType = InnerException.GetType().FullName;
    if (CommandObject != null) 
    {
        IsInTransaction = CommandObject.Transaction != null;
        if (CommandObject.Connection != null) 
        {
            ConnectionString = HideLoginInfo(CommandObject.Connection.ConnectionString);
        }
        SQL = CommandObject.CommandText ?? "";
        ParameterValues = GetCommandParameters();
    }
    InnerExceptions = GetInnerExceptionInfo();
}

Get Inner Exception Information Method

The GetInnerExceptionInfo() method (Listing 17) calls the GetDatabaseSpecificError() method to retrieve database provider specific error information if any exists. If no database-specific error information exists, it returns the inner exception information from the exception object. The GetDatabaseSpecificError() method is overridden in the SqlServerDatabaseException class, or any other database specific exception class.

Listing 17: Return database-specific error information or just the inner exception information

protected virtual string GetInnerExceptionInfo  () 
{
    StringBuilder sb = new(1024);
    Exception ex;
    int index = 1;

    ex = InnerException;
    while (ex != null) 
    {
        sb.AppendLine();
    
        // Is exception a database-specific error?
        string dbSpecific = GetDatabaseSpecificError(ex);
        
        // If no database-specific error, get normal exception info
        if (string.IsNullOrEmpty(dbSpecific)) 
        {
            sb.AppendLine($"  {new('*', 23)}");
            sb.AppendLine($"  * Inner Exception #{index} *");
            sb.AppendLine($"  {new('*', 23)}");
            sb.AppendLine($"  Message: {ex.Message}");
            sb.AppendLine($"  Type: {ex.GetType().FullName}");
            sb.AppendLine($"  Source: {ex.Source}");
            index++;
        }
        else 
        {
            sb.Append(dbSpecific);
        }
        
        // Get next inner exception
        ex = ex.InnerException;
    }

    return sb.ToString();
}

Get All Command Parameter Values Method

If there are parameters in the command object when an exception is thrown, the information in the parameter objects can be very useful. The GetCommandParameters() method, Listing 18, loops through all command parameters and retrieves the ParameterName property and the Value property to build a string that looks something like the following.

@Name = C
@ListPrice = 50

Listing 18: Get the parameter name and value from the parameters in the command object

protected virtual string GetCommandParameters() 
{
    StringBuilder sb = new(1024);

    if (CommandObject != null && CommandObject.Parameters != null) 
    {
        foreach (IDbDataParameter param in CommandObject.Parameters) 
        {
            sb.Append($"  {param.ParameterName}");
            if (param.Value == null || param.Value == DBNull.Value)
                sb.AppendLine(" = null");
            else 
                sb.AppendLine($" = {param.Value}");
        }
    }

    if (string.IsNullOrEmpty(sb.ToString())) 
    {
        return "None";
    }
    else 
    {
        return sb.ToString();
    }
}

If the Value property is a null or a DBNull.Value for the current parameter, the string "= null" is returned. If no parameters are found in the command object, then the string "None" is returned from this method.

Hide Login Information Method

You don't want to display or log any security information contained within the connection string. If a user ID or a password exists in a connection string, it needs to be removed. Within the DatabaseContext class, you're never going to show the connection string at all; you only return asterisks. This method is meant to be overridden in your database-specific exception class so you can filter out the user ID and password there.

Get Database-Specific Error Method

Each database provider has specific exception classes that are used to report errors. The method in the DatabaseException class always returns an empty string. This method is meant to be overloaded within your database-specific exception class.

Add a SQL Server Database Exception Class

If you're using SQL Server as your database, you need a SqlServerDatabaseContext and a SqlServerRepositoryBase class. You also need one more SQL Server-specific class named SqlServerDatabaseException. If you need to access an Oracle database, you're going to have to create three Oracle-specific classes and override the same methods as you have done so far for SQL Server. Right mouse-click on the Common folder and add a new class named SqlServerDatabaseException. Into this new file, add the code shown in Listing 19. You need the same constructors as for the DatabaseException class you created earlier. Each of these constructors should call the ones in the DatabaseException base class. There are two methods to override in this class, HideLoginInfo() and GetDatabaseSpecificError(). For now, just stub them out as shown in Listing 19.

Listing 19: The SqlServerDatabaseException class gathers SQL Server specific information about the exception

#nullable disable

using System.Text;
using System.Data.SqlClient;

namespace AdoNetWrapper.Common;

public class SqlServerDatabaseException: DatabaseException 
{
    public SqlServerDatabaseException(): base() { }
    public SqlServerDatabaseException(string message) : base(message) { }
    public SqlServerDatabaseException(string message, Exception innerException) : base(message, innerException) { }
    public SqlServerDatabaseException(string message, Exception innerException, SqlCommand cmd) : base(message, innerException, cmd) { }

    public override string HideLoginInfo(string connectString) 
    {
        SqlConnectionStringBuilder sb = new(connectString);
        return sb.ConnectionString;
    }

    protected override string GetDatabaseSpecificError(Exception ex) 
    {
        StringBuilder sb = new(2048);
        return sb.ToString();
    }
}

Override the HideLoginInfo() Method

You could do some string parsing in the HideLoginInfo() method contained in the DatabaseContext class, but there are many different kinds of connection strings and the code would get very messy. If you override this method in your database-specific exception classes, you can use the appropriate ConnectionStringBuilder class that each database provider supplies. Locate the HideLoginInfo() method in the SqlServerDatabaseException class and use the SqlConnectionStringBuilder class to replace the user ID and password properties with asterisks. When you then return the ConnectionString property from this method, your user ID and password won't be displayed or logged anywhere.

protected override string HideLoginInfo(string connectString) 
{
    SqlConnectionStringBuilder sb = new(connectString);
    if (!string.IsNullOrEmpty(sb.UserID)) 
    {
        sb.UserID = "****";
    }
    if (!string.IsNullOrEmpty(sb.Password)) 
    {
        sb.Password = "****";
    }
    return sb.ConnectionString;
}

Override the GetDatabaseSpecificError() Method

The database-specific ADO.NET providers supply a specific exception from which they gather information from a database, such as SQL Server or Oracle. When using SQL Server, a SqlException object is generated with lots of great information such as the SQL Server-specific error number, the server name, the procedure name (if applicable), and the line number within the procedure that was in error. Locate the GetDatabaseSpecificError() method and replace the code you stubbed before with the code shown in Listing 20. If you're using Oracle, Progress, or SQLLite, the properties available on those specific exceptions might vary, thus, you will probably write slightly different code in this method.

Listing 20: Override the GetDatabaseSpecificError() method to retrieve SQL Server specific error information

protected override string GetDatabaseSpecificError(Exception ex) 
{
    StringBuilder sb = new(2048);

    if (ex is SqlException exp) 
    {
        for (int index = 0; index <= exp.Errors.Count - 1; index++) 
        {
            var current = exp.Errors[index];
            sb.AppendLine($"  {new('*', 27)}");
            sb.AppendLine($"  * SQL Server Exception #{index + 1} *");
            sb.AppendLine($"  {new('*', 27)}");
            sb.AppendLine($"  Message: {current.Message}");
            sb.AppendLine($"  Exception Type: {current.GetType().FullName}");
            sb.AppendLine($"  Source: {current.Source}");
            sb.AppendLine($"  Server: {current.Server}");
            sb.Append($"  Number: {current.Number.ToString()}");
            sb.Append($"\tState: {current.State.ToString()}");
            sb.AppendLine($"\tClass: {current.Class.ToString()}");
            sb.Append($"  Procedure: {current.Procedure}");
            sb.AppendLine($"\tLineNumber: {current.LineNumber.ToString()}");
        }
    }

    return sb.ToString();
}

Try It Out

Now that you have all the code written to gather exception information, write some code to generate an error so you can test your exception handling. Open the Program.cs file and add a couple of Using statements at the top of the file.

using AdoNetWrapper.Common;
using System.Data;

Write the code shown in Listing 21 to try out the exception handling. In this code, create a SELECT statement that has two invalid column names. Place this SQL statement into a command object and submit that command using the Search() method. Wrap the call to the Search() method into a try...catch block. Catch a DatabaseException object in the catch block and call the ToString() method on the exception to gather the exception information into a string that you can display on the Console window. Run the application to generate the exception information and you should see output like that shown in Figure 1.

Listing 21: Create a bad SQL statement and submit to the database to generate an exception

using AdvWorksDbContext db = new(ConnectString);

IDbCommand cmd= db.CreateCommand("SELECT ID, ProdName FROM SalesLT.Product");

try 
{
    List<Product> list = db.Database.Search<Product>(cmd);

    Console.WriteLine("*** Exception Sample ***");
    // Display Data
    foreach (var item in list) 
    {
        Console.WriteLine(item.ToString());
    }
    Console.WriteLine();
    Console.WriteLine($"Total Items: {list.Count}");
    Console.WriteLine();
    Console.WriteLine($"SQL Submitted: {db.Products.SQL}");
    Console.WriteLine();
}
catch (DatabaseException ex) 
{
    string msg = ex.ToString();
    Console.WriteLine(msg);
    System.Diagnostics.Debug.WriteLine(msg);
}
catch (Exception ex) 
{
    Console.WriteLine(ex.ToString());
}

Summary

In this article series, you created a set of wrappers around ADO.NET that you can use to significantly reduce the amount of code you have to write to perform CRUD operations. All these classes can be put into a class library and reused on many different projects. For each table in a database that you need to interact with, you only need to create small Repository, Entity, and Search classes to be able to search, insert, update, and delete data. These classes are not a full ORM, but the amount of code you need to write is very similar to that of an ORM.