Search Forum
(53671 Postings)
Search Site/Articles

Archived Articles
712 Articles

C# Books
C# Consultants
What Is C#?
Download Compiler
Code Archive
Archived Articles
Advertise
Contribute
C# Jobs
Beginners Tutorial
C# Contractors
C# Consulting
Links
C# Manual
Contact Us
Legal

GoDiagram for .NET from Northwoods Software www.nwoods.com


 
Printable Version

MilkShake.NET Sample Application
By Gildeoni Nogueira Santos

Introduction

Hello buddies… First, this is not some kind of recipe for a milk-shake… I choose this name because this sample involves a mix of .NET technologies like .NET Remoting, ADO.NET, Windows Forms, Data Bindings and many other things. I believe that this new sample app could help a lot of programmers out there beginning with C# understand the principles of these awesome .NET technologies. So, let's go…

Requirements

Before run the sample be sure to have installed on your machine at least SQL Server version 7. The sample was built using the final release of Visual Studio .NET and the .NET Framework (I'm not sure if my sample will work fine on machines running the Beta 2).

Adding Stored Procs to SQL Server Northwind Database

To the task becomes easy open Query Analyzer and then open the sql script "Change_Northwind_Database.sql" that you can find in the project's directory. The script will add a new column named "Row_Version" to the tables "Orders" and "Order Details" in Northwind database, to allow us handle optimistic concurrency, and some stored procs for insert, update and delete actions.

Creating the Server Application

The server app consists in three parts.

-RemoteServices.dll: This assembly contains a shared abstract class called "RemoteDataService", which our service will derive to allow the communication through the remote context, and a shared interface called "IRemoteUpdatableDataService", which is used to handle updates through the remote context.

-Service.dll: This assembly has all the basic classes that we will be used to update and retrieve information from the database. The classes exposed by this assembly derive from "RemoteDataService" and "IRemoteUpdatableDataService".

-Server.exe: This is only a host that will publish our service in the remote context.

Let's see the RemoteServices assembly.

using System;
using System.Data; 

namespace RemoteServices
{
/// <summary>
/// Summary description for IRemoteDataService.
/// </summary>
public abstract class RemoteDataService : MarshalByRefObject
{
	public abstract DataTable GetFilledDataTable();

	public abstract DataTable GetFilledDataTable(string criteria);
}
}
As you can see the abstract class "RemoteDataService" derives from MarshalByRefObject. The class MarshalByRefObject can be found in the System namespace and its used to allow objects to communicate across process boundaries. When a remote app access a MarshalByRefObject object, a proxy will be passed to the remote app and then all the calls to that proxy will be marshaled back to the object in the local process. "RemoteDataService" have only abstract methods, which derived classes must overrides and implement row-retrieval capability.

Below is the "IRemoteUpdatableDataService" interface.

using System;
using System.Data;

namespace RemoteServices
{
/// <summary>
/// Summary description for IRemoteUpdatableDataService.
/// </summary>
public interface IRemoteUpdatableDataService
{
	void Update(ref DataSet dataSet);
}
}
The "IRemoteUpdatableDataService" interface will be used on classes that allow update, such classes are "Service.Data.Orders" and "Service.Data.OrderDetails".

Let's take a brief look in the "Service" assembly.

The class "Customers" derives from "RemoteDataService" and only can retrieve rows from the Customers table in the Northwind database. Below you'll see the method "GetFilledDataTable()" that is overriding the base class method of the same name and implementing the row-retrieval functionality.

public override DataTable GetFilledDataTable(string criteria)
{
	DataTable dataTable = new DataTable("Customers");

	SqlDataAdapter sqlDataAdapter = new SqlDataAdapter();
 
	sqlDataAdapter.SelectCommand = this.SelectCommand;
	sqlDataAdapter.SelectCommand.Parameters[0].Value = criteria;  

sqlDataAdapter.Fill(dataTable);

	sqlDataAdapter.Dispose(); 
 
	return dataTable;
}
The method starts creating a DataTable that will be populated by the Fill method of the sqlDataAdapter object. As you can see we have to configure the SelectCommand property to achieve what we desire here. To configure that we used the following read-only property.
private SqlCommand SelectCommand
{
get {
SqlCommand sqlCommand = new 
SqlCommand("MilkShakeDotNetGetCustomers",      
Connection.SqlConnection); 

	sqlCommand.CommandType = CommandType.StoredProcedure;
 
	sqlCommand.Parameters.Add("@Criteria", SqlDbType.NVarChar, 50);
 
	return sqlCommand;
}
}
The property creates a SqlCommand object that calls our "MilkShakeDotNetGetCustomers" stored proc in the Northwind database and gets the desired rows based on the criteria supplied by the client apps.

Next we'll see the class Orders and what it can do.

The class Orders derives from "RemoteDataService" and "IRemoteUpdatableDataService". This means that the class can read and write in the Northwind database. The row-retrieval functionality is very similar to the others found in the classes exposed by the "Service" assembly and we won't get focus on that, instead we'll get focus on the update functionality. The following method is the "Orders" class implementation of the "IRemoteUpdatableDataService.Update" method.

public void Update(ref DataSet dataSet)
{
sqlDataAdapter.Update(dataSet);
}
Before call the SqlDataAdapter Update method we must set the SqlDataAdapter DeleteCommand, InsertCommand and UpdateCommand properties and this is done in the class constructor. See bellow the SqlCommands that will be used to allow the SqlDataAdapter to update the database.

Delete command:

private SqlCommand DeleteCommand
{
get {
SqlCommand sqlCommand = new 
SqlCommand("MilkShakeDotNetDeleteOrder", Connection.SqlConnection); 
	
sqlCommand.CommandType = CommandType.StoredProcedure;
	
sqlCommand.UpdatedRowSource = UpdateRowSource.None;

	sqlCommand.Parameters.Add("@OrderID", SqlDbType.Int, 4, "OrderID");
				
	return sqlCommand;
}
}
The delete command is very simple it uses the "MilkShakeDotNetDeleteOrder" stored proc. The stored proc deletes the order and all the related order details.

Insert command:

private SqlCommand InsertCommand
{
get {
SqlCommand sqlCommand = new 
SqlCommand("MilkShakeDotNetInsertOrder", Connection.SqlConnection); 
	
sqlCommand.CommandType = CommandType.StoredProcedure;
	sqlCommand.UpdatedRowSource = UpdateRowSource.OutputParameters;

	SqlParameterCollection sqlParams = sqlCommand.Parameters;

	sqlParams.Add("@CustomerID", SqlDbType.NChar, 5, "CustomerID");
	sqlParams.Add("@EmployeeID", SqlDbType.Int, 4, "EmployeeID");
	sqlParams.Add("@OrderDate", SqlDbType.DateTime, 8, "OrderDate");

SqlParameter sqlParam = sqlParams.Add("@OrderID", SqlDbType.Int, 4, 
"OrderID");
	
sqlParam.Direction = ParameterDirection.Output; 
				
sqlParam = sqlParams.Add("@Row_Version", SqlDbType.Timestamp, 8, 
"Row_Version");
	
sqlParam.Direction = ParameterDirection.Output;

	return sqlCommand;
}
}
The Insert command is a bit more complicated. First we create a new SqlCommand object that call the "MilkShakeDotNetInsertOrder" stored proc then we set the CommadType property of the SqlCommand object to StoredProcedure. After that you will see the UpdatedRowSource property of the SqlCommand object. The UpdatedRowSource property accepts a value from the UpdateRowSource enum and it has four members as listed below.

None: Any returned row or parameter is ignored.

FirstReturnedRecord: Returns the first returned row and that row is automatically mapped to the object being updated.

OutputParameters: The output parameters are mapped to the object being updated.

Both: Both first returned row and output parameters are mapped to the object being updated.

The object that I'm talking about could be a DataSet, a DataTable or an array of DataRow objects. We will see how it works later. After set the UpdatedRowSource we begin create the parameters and set their directions. The Direction property of the SqlParameter can be set to one of those members found in the ParameterDirection enum.

Input: The parameter is an input value.

Output: The parameter is an output value.

InputOutput: The parameter is both an input and an output value.

ReturnValue: The parameter is a return value.

Update command:

private SqlCommand UpdateCommand
{
get {
SqlCommand sqlCommand = new 
SqlCommand("MilkShakeDotNetUpdateOrder", Connection.SqlConnection); 
	
sqlCommand.CommandType = CommandType.StoredProcedure;
	sqlCommand.UpdatedRowSource = UpdateRowSource.FirstReturnedRecord;

	SqlParameterCollection sqlParams = sqlCommand.Parameters;

	sqlParams.Add("@OrderID", SqlDbType.Int, 4, "OrderID");
	sqlParams.Add("@CustomerID", SqlDbType.NChar, 5, "CustomerID");
	sqlParams.Add("@EmployeeID", SqlDbType.Int, 4, "EmployeeID");
	sqlParams.Add("@OrderDate", SqlDbType.DateTime, 8, "OrderDate");
sqlParams.Add("@Row_Version", SqlDbType.Timestamp, 8, 
"Row_Version");
				
	return sqlCommand;
}
}
The main difference on the update command is the UpdatedRowSource property that is set to UpdateRowSource.FirstReturnedRecord. As discussed earlier this value returns the first returned row from a batch update.

Be aware when configuring SqlCommand objects. If your stored proc has a return value this must be the first parameter to be added to the SqlParameterCollection. The order of the parameters in the collection must follow the same order of your stored proc.

There's nothing different with the "UpdateCommand" except for the UpdatedRowSource property that is set to FirstReturnedRecord. We can build command objects for the SqlDataAdapter object using a SqlCommandBuilder object but this approach is not efficient as setting all the command properties of the SqlDataAdapter manually. Depending on the case the performance can be ten to fifteen times more slowly using a SqlCommandBuilder to build the command objects.

Now let's see the Server.exe.

The Server.exe is nothing more than a host to our remote service. On this sample we'll use a Server Activated Object activation type. The host application starts registering a channel that will be used for the communication, later on it will configure our service classes in the .NET Remoting runtime as SingleCall objects. The advantage of a SingleCall object is the scalability offered by the stateless model of these objects. When you call a method on a Single Call object the method is executed and then the object resources are released. Each request to a SingleCall object creates a new and independent object on the server. Let's see a fragment of the code that initializes our service.

tcpChannel = new TcpChannel(2121);
  
ChannelServices.RegisterChannel(tcpChannel);

RemotingConfiguration.RegisterWellKnownServiceType (typeof (Countries),                                                                                            
"CountriesUri", WellKnownObjectMode.SingleCall);
As you can see first we create an instance of a TcpChannel class and then we call the static method RegisterChannel() from ChannelServices class passing our tcpChannel object as an argument. With the channel registered the next step is publish our service classes in the .NET Remoting runtime calling the static method RegisterWellKnownServiceType() of the RemotingConfiguration class. This must be well configured to allow our client apps to communicate with the service.

Since our server app is done and ready to receive client requests let's move on to the client side.

The first thing we must do in the client app is create a channel and register it like we did in the server but a little different. See the code of the ConnectToService() Method bellow.

IDictionary props = new Hashtable();
 
props["name"] = "ClientChannel";

tcpChannel = new TcpChannel(props, null, null);
					
channelServices.RegisterChannel(tcpChannel); 
There's no need to create a client channel this way. I did it only to show you how to pass channel properties using programatic configuration. With the channel registered we must create a Proxy on the client for the remote type. The following code shows how.

countries = ((RemoteDataService)Activator.GetObject(typeof(RemoteDataService), "tcp://localhost:2121/CountriesUri")); The countries object above is of type RemoteDataService (the Countries class found in the Service.dll is derived from the abstract class RemoteDataService).

I used this approach to show you how to use shared interfaces and abstract classes with .NET Remoting. The great thing about this model is the maintainability of the application. If something goes wrong inside the method GetFilledDataTable() of the Countries class found in the assembly Service.dll after its deployment we only need to fix that problem, recompile the Service.dll and replace the old one. This could be very useful for a server application that serves hundreds of clients because the need to configure client machines after some problem is dramatically reduced since the client apps doesn't know anything about the code implementation found in the Countries class.

Now we need to fill a ComboBox control with a list of countries.

cbxCountries.DataSource = countries.GetFilledDataTable(); cbxCountries.DisplayMember = "Country";

This step is very easy as you can see. We need only to set the DataSource property of the ComboBox with the DataTable returned from the countries.GetFilledDataTable() method and then set the DisplayMember property of the ComboBox to the column we want to see on it.

To display a list of customers from that country on a DataGrid we need only to set the DataSource property of the DataGrid on the event SelectedIndexChanged of the ComboBox as you can see in the following code snippet.

private void cbxCountries_SelectedIndexChanged(object sender, 
System.EventArgs e) 
{	
dataGrid.DataSource = customers.GetFilledDataTable(cbxCountries.Text);
}
Pressing the button labeled "Get Orders" you'll have the chance to see a form with all the orders from the selected customer. Let's see how the form was built.

When we call the OrdersWindow form a DataSet is built by calling the CreateDataSet() method. Let's see how.

private void CreateDataSet()
{
dataSet = new DataSet("Orders_OrdersDetails");

dataSet.Tables.Add(employees.GetFilledDataTable());
dataSet.Tables.Add(orders.GetFilledDataTable(customerId));
dataSet.Tables.Add(orderDetails.GetFilledDataTable(customerId));
.
.
.
The code starts creating a new instance of a DataSet called "Orders_OrdersDetails" and then adds the necessary tables that will make the form works. Now we can set the DataRelations between the tables, see bellow how you can do it.
.
.
.
dataSet.Relations.Add("EMPLOYEES_ORDERS", dataSet.Tables[0].Columns[0], 
dataSet.Tables[1].Columns[2]);

dataSet.Relations.Add("ORDERS_ORDERDETAILS", 
dataSet.Tables[1].Columns[0], dataSet.Tables[2].Columns[0]);
As you can see ADO.NET is almost perfect to work with disconnected data and very easy to configure too. With the DataSet built we can now do some data bindings. Add data binding to a control is very easy using the Windows Forms technology, see the code bellow.
tbxOrderID.DataBindings.Add("Text", dataSet, "Orders.OrderID");

tbxOrderDate.DataBindings.Add("Text", dataSet, "Orders.OrderDate");
.
.
.
Every control that inherits from Control class has a property called DataBindings. We can use this property to add one or more data bindings to a control. The code above configures the data bindings of the tbxOrderID and tbxOrderDate TextBoxes to the columns OrderID and OrderDate respectively. To configure the ComboBox cbxEmployees to display a list of employees and bind the selected value of the ComboBox to the column EmployeeID in the Orders DataTable we must do the following.
cbxEmployees.DataSource = dataSet;
cbxEmployees.DisplayMember = "Employees.EmployeeName";
cbxEmployees.ValueMember = "Employees.EmployeeID";
cbxEmployees.DataBindings.Add("SelectedValue", dataSet, 
"Orders.EmployeeID");
And to set the DataGrid to the order details of that order we must do the following.

dataGrid.SetDataBinding(dataSet, "Orders.ORDERS_ORDERDETAILS");

With the data binding configurations done we can now add other features to the form such as navigation.

Let's see how we can add navigation functionality to the form.

To navigate through the records of the Orders DataTable we can use a CurrencyManager object. To set the CurrencyManager we can do the following.

currencyManagerOrders = ((CurrencyManager)this.BindingContext[dataSet, "Orders"]);

To navigate to the first record in the Orders DataTable.

currencyManagerOrders.Position = 0;

To navigate to the last record in the Orders DataTable.

currencyManagerOrders.Position = currencyManagerOrders.Count - 1;

To navigate to the previous record in the Orders DataTable.

currencyManagerOrders.Position--;

And to navigate to the next record in the Orders DataTable.

currencyManagerOrders.Position++;

With the code navigation done, let's build the code that will allow us insert, update and delete the rows in the Orders table. To insert a row(s) we can use the following code.

currencyManagerOrders.AddNew();

Calling AddNew() method of the currencyManagerOrders object will automatically add a new row to Orders DataTable and prepare the bound controls to the new row.
To edit the row you have only to change the data on the bound controls. After add a new row or edit one, call the method EndCurrentEdit() of the currencyManagerOrders object. You can even cancel the operation at any time by calling the method CancelCurrentEdit() of the currencyManagerOrders object. To mark a row as deleted we can do the following.

((DataRowView)currencyManagerOrders.Current).Delete();

Using the code above will mark the row as a pending deletion.

Ok, now we must submit the cached changes in the client app to the database.

To submit the changes do the following.

DataSet dataSetChanges = dataSet.GetChanges();

((IRemoteUpdatableDataService)orders).Update(ref dataSetChanges);

((IRemoteUpdatableDataService)orderDetails).Update(ref dataSetChanges);

dataSet.Merge(dataSetChanges.Tables[1]);
dataSet.Merge(dataSetChanges.Tables[2]);

foreach (DataRow dataRow in dataSet.Tables[1].Select(null, null, 
DataViewRowState.Added))
dataSet.Tables[1].Rows.Remove(dataRow);
The code above starts getting a DataSet with the changes and then tries to update the Orders table in the database followed by the OrderDetails. As you can see after successfully update the database the code deletes the added rows of the Orders DataTable, why? The OrderID column in the Northwind's Orders table is auto-incremented and to avoid update errors when inserting new rows in the table we must set the AutoIncrementStep AutoIncrementSeed properties to -1 and the AutoIncrement property to true in Orders DataTable (see the method GetFilledDataTable() in the Service.Data.Orders class). I can pass the dataSet object as an argument in the IRemoteUpdatableDataService.Update() method but the efficiency will not be the same as passing the dataSetChanges object that has only the changes of the dataSet object. The dataSetChanges object is another DataSet so after update the database we must remove the added rows in the dataSet object.

Handling Optimistic Concurrency

To handle optimistic concurrency on this sample I used the ability of SQL Server to handle batch queries and also created a new column called Row_Version. The Row_Version column is of type timestamp, this kind of column have the ability to change its contents every time a row is inserted or updated. So, to handle this type of concurrency using SQL Server as a database the only thing we need to do is capture the new value of the timestamp column every time we insert or update the database. How we can do it? Let's see the stored proc bellow.

CREATE PROCEDURE MilkShakeDotNetInsertOrder
(
	@CustomerID nchar(5),
	@EmployeeID int,
	@OrderDate datetime,
	@OrderID int OUTPUT,
	@Row_Version timestamp OUTPUT
) AS

INSERT INTO Orders(CustomerID, EmployeeID, OrderDate)
VALUES(@CustomerID, @EmployeeID, @OrderDate)

IF (@@ROWCOUNT > 0)
	SELECT @OrderID=OrderID, @Row_Version=Row_Version
	FROM Orders
	WHERE OrderID=@@IDENTITY
The T-SQL code above is from MilkShakeDotNetInsertOrder stored procedure. As you can see when a new row is added to the database the output parameters @OrderID and @Row_Version are configured with the values from the columns OrderID and Row_Version respectively. But its not all. To make every thing works fine we must configure our SqlCommands accordingly. See the code bellow.
private SqlCommand InsertCommand
{
get {
	SqlCommand sqlCommand = new 
SqlCommand("MilkShakeDotNetInsertOrder", Connection.SqlConnection); 
	sqlCommand.CommandType = CommandType.StoredProcedure;
	sqlCommand.UpdatedRowSource = UpdateRowSource.OutputParameters;

	SqlParameterCollection sqlParams = sqlCommand.Parameters;

	sqlParams.Add("@CustomerID", SqlDbType.NChar, 5, "CustomerID");
	sqlParams.Add("@EmployeeID", SqlDbType.Int, 4, "EmployeeID");
	sqlParams.Add("@OrderDate", SqlDbType.DateTime, 8, "OrderDate");

	SqlParameter sqlParam = sqlParams.Add("@OrderID", SqlDbType.Int, 4, 
"OrderID");
	sqlParam.Direction = ParameterDirection.Output; 
				
sqlParam = sqlParams.Add("@Row_Version", SqlDbType.Timestamp, 8, 
"Row_Version");
	sqlParam.Direction = ParameterDirection.Output;

	return sqlCommand;
}
}
Can you see the UpdatedRowSource property of the sqlCommand object? When you configure this property to "UpdateRowSource.OutputParameters" and this SqlCommand is used by a SqlDataAdapter to update the database, this will instructs the SqlDataAdapter to update the corresponding rows with the output parameters retrieved by the stored proc. The same is done with the command used to update the database. So when a user modifies a row, after you read that row, and you try to modify the same row and submit that to the database an exception will be thrown saying that the row has been modified since the last read.

Testing The Sample

1-Start SQL Server

2-Run the SQL script Change_Northwind_Database.sql with Query Analyzer.

3-Open Visual Studio .NET (final release) and then open MilkShake.NET project.

4-The Server app references RemoteServices project and Service project. The Client app references only RemoteServices project. The Service project references RemoteServices project. Make sure that references are correctly configured.

5-The sample is configured to start both the server app followed by the client app. If this configuration is lost right-click MilkShake.NET solution in "Solution Explorer" and then configure the properties as follows.

6-Now run the sample. To test the optimistic concurrency feature start another client app with Windows Explorer and then change the rows as you wish.

Download MilkShake.NET

Ok, that's all for now... See ya next time…