With that said, let's start building the Territory objects. As before, you need to create your four stored procedures for performing all of your operations in the database. Execute the SQL in Listing 7-1 against the Northwind database.
Listing 7-1: The Territory Stored Procedures
USE northwind go CREATE PROCEDURE usp_territory_delete @id nvarchar(20) AS DELETE FROM Territories WHERE TerritoryID = @id go CREATE PROCEDURE usp_territory_getall AS SELECT * FROM Territories go CREATE PROCEDURE usp_territory_getone @id nvarchar(20) AS SELECT * FROM Territories WHERE TerritoryID = @id go CREATE PROCEDURE usp_territory_save @id nvarchar(20), @territory nchar(50), @region_id int, @new bit AS IF @new = 1 INSERT INTO Territories (TerritoryID, TerritoryDescription, RegionID) VALUES (@id, @territory, @region_id) ELSE UPDATE Territories SET TerritoryDescription = @territory, RegionID = @region_id WHERE TerritoryID = @id
You will notice that the Territories table does not have a numeric, autogenerated identifier as a key. Although I do not agree with this approach—because it leaves the burden of creating a primary key value on the user and because it can be any value the user wants—you will leave it as is because that is the way the database was constructed. Once you have created all of the stored procedures, it is time to create the structure and the interface. Then you will create the data-centric business object.
Caution | Having a user-created key on a table can cause an incredible amount of problems in code. You will see why this is a problem later in the "Creating the frmTerritoryList Form" section. I have said it before and I will say it again: All keys in a database should be surrogate keys because it makes everyone's life easier. |
To create the structure and the interface, edit the Structures.vb code module and add the following structure:
<Serializable()> Public Structure structTerritory Public TerritoryID As String Public TerritoryDescription As String Public RegionID As Integer Public IsNew as Boolean End Structure
Notice that you have added a property that does not exist in the database: the IsNew property. In the case of the Region class, it was easy to determine if you were going to perform a save or an update because you could check to see if the ID was zero. Because the TerritoryID is a string, you cannot check it. You also cannot check to see if it is an empty length string because the user needs to set it if it is new—that is one of the rules your object will implement. The only way to have the save stored procedure perform the right operation is to set this flag.
Next you need to create the interface for this object. Add the following interface to the Interfaces.vb code module:
Public Interface ITerritory Function LoadProxy() As DataSet Function LoadRecord(ByVal strID As String) As structTerritory Function Save(ByVal sTerritory As structTerritory) _ As BusinessErrors Sub Delete(ByVal strID As String) Function GetBusinessRules() As BusinessErrors End Interface
Notice that this interface is almost identical to the IRegion interface except for the LoadRecord, Delete, and Save signatures. The difference between these three signatures is the structure that is either passed or returned and the data type of the integer. Also, in the case of the Save method, you do not need to return the ID because that is set by the user. In the next chapter, when you work on reducing your redundant code, this will come into play.
Because this object is materially like the Region object, you will see the code in Listing 7-2 and examine the differences afterward. Add a new class to the NorthwindDC project and call it TerritoryDC. Next, add the code from Listing 7-2.
Listing 7-2: The TerritoryDC Object
Option Strict On Option Explicit On Imports NorthwindTraders.NorthwindShared.Interfaces Imports NorthwindTraders.NorthwindShared.Structures Imports NorthwindTraders.NorthwindShared.Errors Imports System.Configuration Imports System.Data.SqlClient Public Class TerritoryDC Inherits MarshalByRefObject Implements ITerritory Private mobjBusErr As BusinessErrors #Region " Private Attributes" Private mstrTerritoryID As String Private mstrTerritoryDescription As String Private mintRegionID As Integer #End Region #Region " Public Attributes" Public Property TerritoryID() As String Get Return mstrTerritoryID End Get Set(ByVal Value As String) Try If Value Is Nothing Then Throw New ArgumentNullException("Territory ID") End If If Value.Length = 0 Then Throw New ZeroLengthException() End If If Value.Length > 20 Then Throw New MaximumLengthException(20) End If mstrTerritoryID = Value Catch exc As Exception mobjBusErr.Add("Territory ID", exc.Message) End Try End Set End Property Public Property TerritoryDescription() As String Get Return mstrTerritoryDescription End Get Set(ByVal Value As String) Try If Value Is Nothing Then Throw New ArgumentNullException("Territory Description") End If If Value.Length = 0 Then Throw New ZeroLengthException() End If If Value.Length > 50 Then Throw New MaximumLengthException(50) End If mstrTerritoryDescription = Value Catch exc As Exception mobjBusErr.Add("Territory Description", exc.Message) End Try End Set End Property Public Property RegionID() As Integer Get Return mintRegionID End Get Set(ByVal Value As Integer) Try If Value = 0 Then Throw New ArgumentNullException("Region") End If mintRegionID = Value Catch exc As Exception mobjBusErr.Add("Region", exc.Message) End Try End Set End Property #End Region Public Function LoadProxy() As DataSet Implements ITerritory.LoadProxy Dim strCN As String = ConfigurationSettings.AppSettings("Northwind_DSN") Dim cn As New SqlConnection(strCN) Dim cmd As New SqlCommand() Dim da As New SqlDataAdapter(cmd) Dim ds As New DataSet() cn.Open() With cmd .Connection = cn .CommandType = CommandType.StoredProcedure .CommandText = "usp_territory_getall" End With da.Fill(ds) cmd = Nothing cn.Close() Return ds End Function Public Function LoadRecord(ByVal strID As String) As _ structTerritory Implements ITerritory.LoadRecord Dim strCN As String = ConfigurationSettings.AppSettings("Northwind_DSN") Dim cn As New SqlConnection(strCN) Dim cmd As New SqlCommand() Dim da As New SqlDataAdapter(cmd) Dim ds As New DataSet() Dim sTerritory As structTerritory cn.Open() With cmd .Connection = cn .CommandType = CommandType.StoredProcedure .CommandText = "usp_territory_getone" .Parameters.Add("@id", strID) End With da.Fill(ds) cmd = Nothing cn.Close() With ds.Tables(0).Rows(0) sTerritory.TerritoryID = Convert.ToString(.Item("TerritoryID")) sTerritory.TerritoryDescription = _ Convert.ToString(.Item("TerritoryDescription")) sTerritory.RegionID = Convert.ToInt32(.Item("RegionID")) End With ds = Nothing Return sTerritory End Function Public Sub Delete(ByVal strID As String) Implements ITerritory.Delete Dim strCN As String = ConfigurationSettings.AppSettings("Northwind_DSN") Dim cn As New SqlConnection(strCN) Dim cmd As New SqlCommand() cn.Open() With cmd .Connection = cn .CommandType = CommandType.StoredProcedure .CommandText = "usp_territory_delete" .Parameters.Add("@id", strID) .ExecuteNonQuery() End With cmd = Nothing cn.Close() End Sub Public Function Save(ByVal sTerritory As structTerritory) _ As BusinessErrors Implements ITerritory.Save Dim strCN As String = ConfigurationSettings.AppSettings("Northwind_DSN") Dim cn As New SqlConnection(strCN) Dim cmd As New SqlCommand() Dim intNew As Integer mobjBusErr = New BusinessErrors() With sTerritory Me.TerritoryID = .TerritoryID Me.TerritoryDescription = .TerritoryDescription Me.RegionID = .RegionID End With If mobjBusErr.Count = 0 Then If sTerritory.IsNew Then intNew = 1 Else intNew = 0 End If cn.Open() With cmd .Connection = cn .CommandType = CommandType.StoredProcedure .CommandText = "usp_territory_save" .Parameters.Add("@id", mstrTerritoryID) .Parameters.Add("@territory", mstrTerritoryDescription) .Parameters.Add("@region_id", mintRegionID) .Parameters.Add("@new", intNew) .ExecuteNonQuery() End With cmd = Nothing cn.Close() End If Return mobjBusErr End Function Public Function GetBusinessRules() As BusinessErrors _ Implements ITerritory.GetBusinessRules Dim objBusRules As New BusinessErrors() With objBusRules .Add("Territory ID", "The value cannot be null.") .Add("Territory ID", "The value cannot be more than 20 " _ & "characters in length.") .Add("Territory Description", "The value cannot be null.") .Add("Territory Description", "The value cannot be more " _ & "than 50 characters in length.") .Add("Region", "The value cannot be null.") End With Return objBusRules End Function End Class
That is a lot of code, but after this you will not have to type in that much code at one time for the rest of the project! The biggest change in Listing 7-2 is the Save method. Because you already know what the ID of the new record is, you do not have to pass it back. Aside from this, this code is almost identical to the code for the RegionDC class.
The next thing you need to do is to build the user-centric Territory classes. This is where you have to implement the choice you made concerning the object relationships. The user-centric business objects are coupled with the user interface. It is not a tight coupling, but many times you make choices to support your own user interfaces. If you make this object loosely coupled and another application wants to use your user-centric business objects, they may not realize they need to instantiate the Region objects on their own and therefore your Territory object will not be complete. So, you will create an association between this class and the Region class because it is a simple starting point. Because this class is an aggregate of another class, you will see some slightly different behavior in your class.
Add a new class module to the NorthwindUC project called Territory.vb. As before, add the following code to the top of the Territory.vb code module:
Option Strict On Option Explicit On Imports NorthwindTraders.NorthwindShared.Structures Imports NorthwindTraders.NorthwindShared.Interfaces Imports NorthwindTraders.NorthwindShared.Errors Imports NorthwindTraders.NorthwindShared
Now, let's start adding code to your Territory class.
Private WithEvents mobjRules As BrokenRules Private mblnDirty As Boolean = False Public Loading As Boolean Private Const LISTENER As String = "TerritoryDC.rem" Private msTerritory As structTerritory Private mblnNew As Boolean Public Event BrokenRule(ByVal IsBroken As Boolean) Public Event ObjectChanged(ByVal sender As Object, _ ByVal e As ChangedEventArgs)
A new variable in the preceding code (if you compare this to the Region class) is the mblnNew variable. This is the flag you will set to indicate whether the object is new. Besides that change, the code is the same so far. This is the reason for using the generic object to pass back your object in the ObjectChanged event; you can copy and paste it everywhere without making a single change to the signature.
Note | In the next chapter you will see how you can reduce a large amount of redundancy within the two classes you have created so far (the Region and Territory classes). It allows you to create a standard base class that can be used by everything and that ensures you have implemented the BrokenRules class and several other things you have determined as mandatory for your classes. |
Add the following private member attributes to the Territory class:
#Region " Private Attributes" Private mstrTerritoryID As String = "" Private mstrTerritoryDescription As String = "" Private mobjRegion As New Region #End Region
Notice that instead of a RegionID variable you have an instance of the Region object. This is the actual object dependency and it forces you to load and refresh your object in a slightly different way. Add the public attributes of your class as shown in Listing 7-3.
Listing 7-3: Public Attributes of the Territory Class
#Region " Public Attributes" Public Property TerritoryID() As String Get Return mstrTerritoryID End Get Set(ByVal Value As String) Try If Value Is Nothing Then Throw New ArgumentNullException("Territory ID") End If If Value.Length = 0 Then Throw New ZeroLengthException() Else If Value.Length > 20 Then Throw New MaximumLengthException(20) End If End If If mstrTerritoryID <> Value Then mstrTerritoryID = Value If Not Loading Then mobjRules.BrokenRule("Territory ID", False) mblnDirty = True End If End If Catch exc As Exception mobjRules.BrokenRule("Territory ID", True) mstrTerritoryID = Value mblnDirty = True Throw exc End Try End Set End Property Public Property TerritoryDescription() As String Get Return mstrTerritoryDescription End Get Set(ByVal Value As String) Try If Value Is Nothing Then Throw New ArgumentNullException("Territory Description") End If If Value.Length = 0 Then Throw New ZeroLengthException() Else If Value.Length > 50 Then Throw New MaximumLengthException(50) End If End If If mstrTerritoryDescription <> Value Then mstrTerritoryDescription = Value If Not Loading Then mobjRules.BrokenRule("Territory Description", False) mblnDirty = True End If End If Catch exc As Exception mobjRules.BrokenRule("Territory Description", True) mstrTerritoryDescription = Value mblnDirty = True Throw exc End Try End Set End Property Public Property Region() As Region Get Return mobjRegion End Get Set(ByVal Value As Region) Try If Value Is Nothing Then Throw New ArgumentNullException("Region") End If If Not Value Is mobjRegion Then mobjRegion = Value If Not Loading Then mobjRules.BrokenRule("Region", False) mblnDirty = True End If End If Catch exc As Exception mobjRules.BrokenRule("Region", True) mobjRegion = Value mblnDirty = True Throw exc End Try End Set End Property #End Region
This follows the exact same pattern as the one you used when you created your Region object, except for the inclusion of a Region object here. Notice that you are using a property for the Region object that accepts a Region object ByVal. In actual practice, however, this is a reference type and so it is passed by reference. You will see this once you are done creating this object and you test it. It should be noted that the Region objects associated using this property will not be destroyed if you destroy this object. You should only do this type of setup for object relationships that consist of dependencies, rather than true aggregations. In a true aggregation, the supplier object (Region) would be created by the client object (Territory).
Tip | One of the great powers of this type of setup is the consistency of the code. You do not have to do anything radically different from one class to the other, which helps make maintenance incredibly easy. It also makes reading the code fairly easy because if you understand it in one place, you understand it in all places. |
Next, add the IsDirty and IsValid properties (or copy them from the Region object) as shown in Listing 7-4.
Listing 7-4: The IsDirty and IsValid Methods
Public ReadOnly Property IsDirty() As Boolean Get Return mblnDirty End Get End Property Public ReadOnly Property IsValid() As Boolean Get If mobjRules.Count > 0 Then Return False Else Return True End If End Get End Property
Although the IsDirty and IsValid classes remain the same, the constructors will be slightly different. Add the constructors as shown in Listing 7-5.
Listing 7-5: The Territory Class Constructors
Public Sub New() mblnNew = True mobjRules = New BrokenRules() mobjRules.BrokenRule("Territory ID", True) mobjRules.BrokenRule("Territory Description", True) mobjRules.BrokenRule("Region", True) End Sub Public Sub New(ByVal strID As String) mblnNew = False mobjRules = New BrokenRules() mstrTerritoryID = strID End Sub
In the first constructor, you set the New flag to true, which is the indicator to the stored procedure to execute the correct block of code when you go to save your object. Also, in this method, you set all of the properties as broken because they are all required and none of them have defaults. The second constructor is similar to the Region constructor except that you set the New flag to false because any changes will indicate an update to the object, not a new object. Also, notice you do not break any rules. The reason for this is that it is assumed you will load your object with valid values, so this saves a bunch of unneeded processing. Also, you have a backup system here because if you assign properties that are invalid, the rules will be broken for you.
Add the ToString function as follows:
Public Overrides Function ToString() As String Return mstrTerritoryDescription End Function
The ToString function remains the same, except that you now return the Territory Description. Next, add the LoadRecord routine. You will recall that this routine is used either to fully load the record if it is only partially loaded or to refresh the object from the database so that when a user goes to edit the record, they are editing the latest record. Add the LoadRecord method as shown in Listing 7-6.
Listing 7-6: The LoadRecord Method
Public Sub LoadRecord() Dim objITerritory As ITerritory Dim sTerritory As structTerritory Dim objRegionMgr As RegionMgr objITerritory = CType(Activator.GetObject(GetType(ITerritory), _ AppConstants.REMOTEOBJECTS & LISTENER), ITerritory) sTerritory = objITerritory.LoadRecord(mstrTerritoryID) objITerritory = Nothing With sTerritory Me.mstrTerritoryID = .TerritoryID Me.mstrTerritoryDescription = .TerritoryDescription.Trim End With objRegionMgr = objRegionMgr.GetInstance mobjRegion = objRegionMgr.Item(sTerritory.RegionID) sTerritory = Nothing End Sub
This method is similar to the Region class's LoadRecord method except for the addition of the RegionMgr object. The advantage of creating the RegionMgr object as a Singleton object is apparent. If you have not loaded the RegionMgr object at this point, it is loaded. Then you retrieve an object from the Manager. If the object is loaded, then you do not have to go to the database to get the object. See the sidebar "Singleton Object Dangers" for additional information.
You need to be aware of a couple of things with Singleton objects and aggregation. What if there was a new Region added to the database and the current Territory you were working with was updated so that it was assigned to this new Region? And what if the old Region had been deleted? This scenario is not likely in this situation, but let's look at what you would have to do to take care of this problem.
First, when the updated Territory information is received from the database, you would need to check the value of the RegionID against the value of the RegionID in the Region object. If they matched, then there is no problem and you can just call the Load method on the aggregate object. If they did not match, well, this is where it gets sticky. First, you would need to get a reference to the RegionMgr object and check to see if the object was in the collection. If it is in the collection, then there is no problem. You just assign that Region object to your Territory object and call the Load method on it. If it was not there, you would need to call the Refresh method on the RegionMgr object and then get a reference to the new Region and assign it to your Territory object.
Although all of this works great, there is the small problem in that your objects are no longer loosely coupled. Instead they are tightly bound and one object needs to know everything about the other object. Of course, this is precisely what an aggregate is, but remember that it is your choice as to whether you aggregate your objects. There are no hard and fast rules, and this type of scenario demands that you examine your object relationships carefully before you begin coding.
Next is the Delete method, which again is the same except for the object that is being deleted. Add the code for this method as shown in Listing 7-7.
Listing 7-7: The Territory Delete Method
Public Sub Delete() Dim objITerritory As ITerritory objITerritory = CType(Activator.GetObject(GetType(ITerritory), _ AppConstants.REMOTEOBJECTS & LISTENER), ITerritory) objITerritory.Delete(mstrTerritoryID) objITerritory = Nothing End Sub
Next, add the Save method shown in Listing 7-8 to the Territory class.
Listing 7-8: The Territory Save Method
Public Sub Save() If mobjRules.Count = 0 Then If mblnDirty = True Then Dim objITerritory As ITerritory Dim sTerritory As structTerritory With sTerritory .TerritoryID = mstrTerritoryID .TerritoryDescription = mstrTerritoryDescription .RegionID = mobjRegion.RegionID .IsNew = mblnNew End With objITerritory = _ CType(Activator.GetObject(GetType(ITerritory), _ AppConstants.REMOTEOBJECTS & LISTENER), ITerritory) objITerritory.Save(sTerritory) objITerritory = Nothing If mblnNew Then mblnNew = False RaiseEvent ObjectChanged(Me, New _ ChangedEventArgs(ChangedEventArgs.eChange.Added)) Else RaiseEvent ObjectChanged(Me, New _ ChangedEventArgs(ChangedEventArgs.eChange.Updated)) End If End If End If End Sub
The only difference between this method and the Save method in the Region class is that you check the value of the mblnNew flag instead of checking to see if the ID is equal to 0. Note also that you have to manually set the New flag to false once the object has been saved. The last two methods, GetBusinessRules and BrokenRules, are the same (shown in Listing 7-9). Add these methods to the Territory class.
Listing 7-9: The GetBusinessRules and BrokenRules Methods
Public Function GetBusinessRules() As BusinessErrors Dim objITerritory As ITerritory Dim objBusRules As BusinessErrors objITerritory = CType(Activator.GetObject(GetType(ITerritory), _ AppConstants.REMOTEOBJECTS & LISTENER), ITerritory) objBusRules = objITerritory.GetBusinessRules objITerritory = Nothing Return objBusRules End Function Private Sub mobjRules_RuleBroken(ByVal IsBroken As Boolean) _ Handles mobjRules.RuleBroken RaiseEvent BrokenRule(IsBroken) End Sub
Next you need to add the TerritoryMgr class. This class is also a Manager class, but it is not a Singleton. The reason for this is that you do want to know what territories the employee is a part of, so every employee you load needs to have its own collection of territories. Listing 7-10 shows the code for the TerritoryMgr class. Add this code to the Territory.vb code module.
Listing 7-10: The TerritoryMgr Class
Public Class TerritoryMgr Inherits System.Collections.DictionaryBase Public Sub New Load End Sub Public Sub Add(ByVal obj As Territory) dictionary.Add(obj.TerritoryID, obj) End Sub Public Function Item(ByVal Key As Object) As Territory Return CType(dictionary.Item(Key), Territory) End Function Public Sub Remove(ByVal Key As Object) dictionary.Remove(Key) End Sub Private Sub Load() Dim objITerritory As ITerritory Dim dRow As DataRow Dim ds As DataSet Dim objRegionMgr As RegionMgr objITerritory = CType(Activator.GetObject(GetType(ITerritory), _ AppConstants.REMOTEOBJECTS & "TerritoryDC.rem"), ITerritory) ds = objITerritory.LoadProxy() objITerritory = Nothing objRegionMgr = objRegionMgr.GetInstance For Each dRow In ds.Tables(0).Rows Dim objTerritory As New _ Territory(Convert.ToString(dRow.Item("TerritoryID"))) With objTerritory .Loading = True .TerritoryDescription = _ Convert.ToString(dRow.Item("TerritoryDescription")).Trim .Region = _ objRegionMgr.Item(Convert.ToInt32(dRow.Item("RegionID"))) .Loading = False End With Me.Add(objTerritory) Next ds = Nothing End Sub End Class
The differences to note in the Load method revolve around the RegionMgr object. As with the Load method in the Territory class, you need to get a reference to the RegionMgr object. Remember that the RegionMgr object is a shared object, so if the Regions have been loaded prior to this, the reference is established very quickly; otherwise the information must be retrieved from the database. Then, as you load the Territory objects, you assign a Region object based on the key, which in this case, is the RegionID that you pulled back and you returned to your Territory object. Because you are assigning the Region objects to your Territory objects in this way, you do not need to instantiate a new Region object for every Territory object that you instantiate. This makes the load process fairly quick.
Now that you have added the code, you need to make another entry in the web.config file. Add the following tag so that you can access the TerritoryDC object:
<wellknown mode="Singleton" type="NorthwindTraders.NorthwindDC.TerritoryDC, NorthwindDC" objectUri="TerritoryDC.rem"/>