Fitch & Mather Sample: Database Design

Scott Stanfield
Vertigo Software, Inc.

September 1998

Summary: Discusses the design of the Fitch & Mather database. (30 printed pages) Includes:

Introduction
Building the Data Model
Overall Database Design
Table Details
Table Design Notes
Triggers
Stored Procedures
Conclusion
About the Author
For More Information

Introduction

This article describes the design of the Fitch & Mather database. It discusses the methods used to create a logical database based on Fitch & Mather's requirements. A physical implementation of the logical design was created in Microsoft® SQL Server™ 6.5. A list of stored procedures and triggers and what each one does is included in this document.

Building the Data Model

MSDN's Steve Kirk described a method to build a data model from use cases. The following is a quote from his article describing database design for Duwamish Books, Phase 1, an example application:

Statements of the business transactions to be modeled provide a starting point for the data model. The nouns in these statements can be thought of as data and the verbs as the processes that operate upon data. The nouns are developed into data entities with attributes that describe the entities and the relationships between entities. This suite of statements represents the primary business processes of the bookstore with the emphasized Nouns and Verbs forming the basic data model. Words used as NOUNS are in all caps and those used as VERBS are in italic caps. ("Designing a Simple Retail-Oriented Database")

We've adopted the approach suggested by Steve Kirk in the Duwamish Books application and applied the techniques here. Think of each sentence in bold a statement of fact, or a simplification of how different entities relate to each other.

From these statements, we should be able to derive a list of tables (or entities) and their relationships (one-to-many or many-to-many). This knowledge will be used to translate the statements to a logical diagram, and then to a physical diagram.

To make the test cases more realistic, we're modeling the database after a two-person trip to Comdex. This will enable us to test the database design against a real-world scenario.

A USER completes EXPENSE REPORTS.

Example Alice and Dolores incur expenses over a three-day period at Comdex. They both file expense reports for the trip.

An expense report is completed on an individual basis. Both managers and employees submit expense reports. They are both USERS of the system.

An EXPENSE REPORT has one or more LINE ITEMS.

Example Alice paid for the room at the Holiday Inn (1), a taxi cab to and from the airport (2, 3), and meals at the Spanish Steps Restaurant at Caesars Palace (4, 5). Her expense report will have five line items.

It doesn't make much sense to file an expense report with no line items, but the system will allow this. There is no easy way to enforce the cardinality that requires an ExpenseReport to have at least one item because, at some point during creation, an ExpenseReport will have zero line items. The expense report total amount is derived from the sum of the LINE ITEMS, so an expense report with no line items will have a reimbursement amount of $0.00.

Each LINE ITEM falls into a single EXPENSE CATEGORY.

Example The hotel room expense will fall under the "Room" category. All meals are entered under the "Food" category. Any travel expense, including plane, bus and taxi cabs, are filed under the "Travel" category.

The expense report template has columns that correspond to categories in the database. If a line item doesn't match a category, the entire transaction will be aborted and the expense report won't be recorded.

Some USERS have a maximum amount for each EXPENSE REPORT.

Example Because Alice is the VP of Sales, she has no expense report limit. Dolores, on the other hand, is limited to a maximum of $500 per expense report and a $100 limit on entertainment expenses.

Regardless of the line item expenses, we will restrict some users to a maximum reimbursement limit. Notice that this is a limit only on an individual expense report. Nothing in the database would prevent users from submitting 30 expense reports in one month. Monthly limits would be a nice enhancement for a future version.

Some USERS have spending LIMITS for some CATEGORIES.

Example Dolores does a minimal amount of entertaining for clients, so she is limited to $100 for entertainment expenses. Jack, on the other hand, frequently "wines and dines" prospective clients, so his entertainment limit is $300.

Fitch & Mather would like to apply spending limits on individual categories for particular users. This would be useful if a particular employee, for example, is allowed to expense their fuel costs, but only up to $50.

Some CATEGORIES have global limits.

Example No one, not even managers, can submit expense reports with fuel expenses greater than $100.

If necessary, a limit can be applied on a category basis, across the board. No line item would be allowed to exceed this limit.

A MANAGER approves or denies their USER's EXPENSE REPORTS.

Example Alice approves Dolores's expense report. She denies Jack's expense report because the description for one of the line items isn't clear. Because she doesn't report to any other administrative entity, she approves her own expense report.

It becomes clear from this example that managers wear two hats. On one hand, they submit expense reports for reimbursement just like everyone else. On the other hand, they are responsible for approving expenses.

There is an interesting case to examine. If a manager doesn't have a boss, who approves their expense report? The database will allow the manager to approve his or her own expense report in this case.

Because managers and employees are equivalent, a single table called USERS will be sufficient to track all employees.

An EXPENSE REPORT is in one of three STATES: waiting for approval, approved, or denied.

Example When Dolores uploads her expense report, it is entered into the database and tagged as "waiting for approval." Alice can review the report and change the status to "approved" or "denied." Either of these actions will generate an e-mail message to Dolores to notify her of the change.

We'll put the possible states into a separate table called Status. Future versions of FMCorp might add more states, like "waiting for receipts" or "routed to accounting."

Overall Database Design

The FMCorp database is a SQL Server database. SQL Server offers a rich set of tools for database architecture. The FMCorp database takes advantage of many ANSI-SQL 92 features.

Figure 1 shows the tables in FMCorp.

Figure 1. FMCorp database design

A key icon in the first column of the table identifies the primary key for each table. A line connecting the foreign key (FK) to the referring table indicates foreign keys. You can locate the FK by finding the infinity sign.

The infinity sign represents the "many" side in a one-to-many relationship. For example, a user can, theoretically, submit an infinite number of expense reports. You read this relationship by following the link between Users and ExpenseReports.

This diagram was generated using the Database Diagram option in the Microsoft Visual InterDev™ Web development system. I added the text boxes by hand. The layout and text is actually saved in the database.

If you are familiar with designs in Visual InterDev, you might notice that the relationship lines are a thatched color instead of solid. That's because we declared the relationship between each table in SQL script, but dropped the automatic enforcement. This is a feature of SQL 92 called Declarative Referential Integrity, or DRI. Later you'll see how triggers are more powerful than the automatic DRI.

Table Details

In Figure 2, you'll see the table names and relations, but not any other information about each column. This section describes in detail the implementation and purpose of each table. The actual SQL create table statement is shown as well because the declarative referential integrity is defined in these statements.

Users Table

The Users table stores the list of employees that are allowed to use the system. Figure 2 shows the column properties in table properties in Visual InterDev.

Figure 2. Column properties for the Users table

The actual SQL statement used to create the Users table is shown here:

create table Users 
(
   UserID         int            NOT NULL IDENTITY PRIMARY KEY,
   FirstName      varchar(50)      NOT NULL,
   LastName      varchar(50)      NOT NULL,
   Password      varchar(50)      NOT NULL DEFAULT ('password'),
   NTLogin         varchar(50)      NULL,
   ManagerID      int            NOT NULL REFERENCES Users(UserID),
   Email         varchar(50)      NOT NULL UNIQUE,
   Limit         smallmoney      NULL DEFAULT(NULL)
)

There is always one special row in the database with a UserID of 1: the Administrator. It is added when you run the FMCorp.sql script. The delete trigger on the Users table prevents it from being deleted. If it were deleted, it would not be possible to add a new user using the Web site because the ManagerID must refer to an existing user.

How does SQL Server prevent this scenario? SQL Server allows you to insert a record into a table and reference the new key in the same statement. Here's the line from the "sp_populate" query that initializes the table:

   Insert Into Users (FirstName,LastName,password,ntlogin,ManagerID,email) 
   Values('Admin','Administrator','',NULL, 1, 'info@fmcorp.com')

We know that this will be the first record in the Users table, and that the UserID identity column seed starts at 1 by default. Therefore, a 1 is placed in the ManagerID slot.

The same technique is used to add Alice Campbell because she managers herself. If we had to add her to the database using the AdminUsers Web page, we'd have to make her manager Admin first, and then change it to Campbell later.

The default contents of the Users table are shown in Figure 3.

Figure 3. Initial contents of the Users table

ExpenseReports Table

The ExpenseReports table holds expense reports. Figure 4 shows the column properties for the each expense report.

Figure 4. Column properties for ExpenseReports table

create table ExpenseReports 
(
   ExpenseReportID   int               NOT NULL IDENTITY PRIMARY KEY,
   UserID            int               NOT NULL REFERENCES Users(UserID),
   Description         varchar(255)      NOT NULL,
   Submitted         smalldatetime      NOT NULL DEFAULT (getdate()),
   StatusID         int               NOT NULL DEFAULT (1) 
                                 REFERENCES StatusTypes (StatusID),
   StatusDate         smalldatetime      NOT NULL DEFAULT (getdate())
)

The default data added by the stored procedure sp_populate is shown in Figure 5.

Figure 5. Initial contents of the ExpenseReports table

Items Table

The Items table stores the individual line items, or expenses, in a given expense report. Each entry should correspond to a single receipt, although this system doesn't track receipts.

Figure 6. Column properties for Items table

create table Items 
(
   ItemID            int               NOT NULL IDENTITY PRIMARY KEY,
   ExpenseReportID   int               NOT NULL REFERENCES 
                                 ExpenseReports (ExpenseReportID),
   Description         varchar(255)      NOT NULL,
   ExpenseDate         smalldatetime      NOT NULL DEFAULT (getdate()),
   Amount            smallmoney         NOT NULL DEFAULT (0),
   CategoryID         int               NOT NULL REFERENCES
                                 ExpenseCategories(CategoryID) 
                                 DEFAULT (1)
)

The initial data for this table is shown here in Figure 7.

Figure 7. Initial contents of the Items table

StatusTypes Table

StatusTypes holds a list of valid states for an expense report. There are currently only three states, so it is a simple table.

Figure 8. Column properties for StatusTypes table

create table StatusTypes 
(
   StatusID      int               NOT NULL PRIMARY KEY,
   Description      varchar(30)         NOT NULL
)

The StatusTypes table is a "range" table. It restricts the StatusID column in the ExpenseReports table to a small set of valid numbers. We could also enforce this with an insert/update trigger in the ExpenseReports table, but then we'd need a place to store the description. Storing the description in this table allows for easier localization and opens the system up for new status types.

The only values used in FMCorp are added when the database is initially created. They are shown in Figure 9.

Figure 9. Initial values for StatusTypes

Limits Table

The Limits table allows the administrator to enforce dollar limits on a per-user and per-category basis. An entry in this table will restrict a particular user from submitting an expense report with a large dollar amount for a particular category.

Figure 10. Column properties for Limits table

create table Limits 
(
   UserID         int            NOT NULL REFERENCES Users(UserID),
   CategoryID      int            NOT NULL REFERENCES 
                           ExpenseCategories(CategoryID) DEFAULT (1),
   Limit         smallmoney      NOT NULL DEFAULT (0),

   constraint Limits_PK primary key (UserID, CategoryID)
)

Because this table's primary key is a composite of UserID and CategoryID, the separate constraint line at the bottom is necessary to define this. Usually when you see a composite key made up of two foreign keys, it is a good indication that this table is in the middle of a many-to-many relationship. In this case, there is a many-to-many relationship between Users and Categories. The attribute on that relationship, in this case, is a dollar limit.

In order to test the robustness of the triggers and this table, you can submit an expense report for Jack that has a $500 Room line item. Next, use the AdminLimits table to limit his room expenses to only $200. Try to submit the report and the new transaction will be rolled back.

This table has no initial entries.

ExpenseCategories Table

Figure 11 shows the simple ExpenseCategories table. In addition to working as a simple list of possible expense categories, it also stores the global dollar limit for each category.

Figure 11. Column properties for ExpenseCategories table

create table ExpenseCategories 
(
   CategoryID      int            NOT NULL PRIMARY KEY,
   Description      varchar(30)      NOT NULL,
   Limit         smallmoney      NULL DEFAULT(NULL)
)

The initial values for this table are shown in Figure 12. The descriptions for CategoryID 2 through 7 correspond to the columns in the Excel expense report template.

Figure 12. Initial contents of the Limits table

Table Design Notes

Some tips and tricks for table design are presented in the following section.

Why do you Code Tables by Hand?

Which is more readable? This:

CREATE TABLE "dbo"."Limits" (
   "UID" "int" NOT NULL ,
   "ETypeID" "int" NOT NULL CONSTRAINT "DF__Limits__ETypeID__7FCB01AD" DEFAULT (1),
   "DollarLimit" "smallmoney" NOT NULL 
      CONSTRAINT "DF__Limits__DollarLi__00BF25E6" DEFAULT (0),
   CONSTRAINT "Limits_PK" PRIMARY KEY  CLUSTERED 
   (
      "UID",
      "ETypeID"
   ),
   CONSTRAINT "FK__Limits__ETypeID__7ED6DD74" FOREIGN KEY 
   (
      "ETypeID"
   ) REFERENCES "dbo"."ExpenseTypes" (
      "ETypeID"
   ),
   CONSTRAINT "FK__Limits__UID__7DE2B93B" FOREIGN KEY 
   (
      "UID"
   ) REFERENCES "dbo"."Users" (
      "UID"
   )
)
GO

Or this:

create table Limits 
(
   UID            int         NOT NULL REFERENCES Users(UID),
   ETypeID         int         NOT NULL REFERENCES ExpenseTypes(ETypeID) DEFAULT (1),
   DollarLimit      smallmoney   NOT NULL DEFAULT (0),

   constraint Limits_PK primary key (UID, ETypeID),
)
go

The scripting feature in SQL Server creates the first version. I hand-tuned each create table statement into the form shown in the second version.

I find the second version is more readable because the foreign keys are identified on the same line with the column name. The extra double quotation marks are removed because none of our column names include strange characters that need quoting.

Each foreign key constraint has a name. SQL Server generates one for us if we don't specify a name, but is "DF__Limits__DollarLi__00BF25E6" very useful? By removing the constraint names, we'll let SQL Server silently generate these names for us and we'll never need to see them again.

You might be saying that the constraint names will appear in a raised error message, but later you'll see that we actually turn off all the DRI constraints. We do this in order to take advantage of advanced triggers.

Visual InterDev still uses the DRI to draw the lines between tables on the database design editor. Plus the declared foreign keys are self-documenting.

Use of Identity Columns

Before identity columns were added to SQL Server, implementing a similar feature was difficult and didn't scale well. Some techniques required a special table to keep track of the current maximum value and an atomic stored procedure to read and update the value.

Now identity columns make declaring a primary key very easy. You can use them in tough situations that would necessitate the use of a composite key (like the example in the preceding Items discussion) or a primary key based on text (using the LastName field in the Users table).

Because all of our identity primary keys are 4-byte integers, they don't take up too much space as a percentage of total bytes in a record. They're also easy to pass between stored procedures and the middle-tier objects that use them.

One more benefit of identity columns is that their values can't change, so you don't have to verify their uniqueness in an update trigger.

Triggers

Triggers are the unsung heroes of relational databases. They are the last line of defense against bad data. We use them here to enforce our referential integrity and business rules.

Translate Business Rules to Triggers

Let's talk business rules. Some three-tier pundits want all the business rules enforced in the middle tier. Forget the middle tier—it's too cumbersome. The best place to enforce business rules that deal with data is right before the data is written to the database. That way, if you have extra add-on applications later that don't use the middle-tier objects, they'll get caught by the triggers.

Here's a short list of the business rules we have to enforce:

  1. Don't allow employees to be removed from the system if they have submitted any expense reports.

  2. Don't allow the deletion of a manager if they are currently managing other employees. Instead, the employees must be manually reassigned to a different manager before the manager can be deleted.

  3. An alternate approach would be to reassign the "orphaned" users to the Admin user. A small change to the Users delete trigger would allow this.

  4. Don't allow the removal of the Admin user.

  5. Don't allow users to be added to the system without specifying a valid manager first.

  6. Don't allow expense items if they exceed maximum allowed values for that category.

  7. Don't allow expense items for a particular user if they exceed the limits specified in the Limits table.

  8. Don't allow an expense item if it kicks the expense report over the limit allowed in the User table.

Custom triggers are the only way to effectively enforce these business rules while still maintaining an open database.

Enforce DRI through Triggers

There is one downside to using triggers to enforce the business rules: They'll never fire if DRI is enabled!

This happens because constraint violations are tested before triggers fire. This means you can't use FOREIGN KEY constraints in the table definition if you want triggers to work. You have to alter each table to remove their DRI using the NO CHECK option. The following SQL script in FMCorp.sql illustrates how DRI is disabled:

alter table Users            NOCHECK CONSTRAINT ALL
alter table ExpenseCategories   NOCHECK CONSTRAINT ALL
alter table Limits            NOCHECK CONSTRAINT ALL
alter table ExpenseReports      NOCHECK CONSTRAINT ALL
alter table Items            NOCHECK CONSTRAINT ALL
alter table StatusTypes      NOCHECK CONSTRAINT ALL

We still use DRI to self-document the tables; it just won't be used to enforce foreign-key integrity.

Now we have to reconstruct all the work DRI did for us through custom triggers. Fortunately, there's an easy way to do this.

Spot-the-Trigger Game

Now that DRI is out of the picture, we have to do the work by hand. How do you know where to add triggers? Follow these steps:

  1. Print a copy of the database diagram with the relationship lines.

  2. Draw a "D" next to each key symbol. Draw an "IU" next to every infinity symbol.

  3. Draw a box around each table making sure you enclose the "D"s or the "IU"s as necessary.

  4. If a table has just "D"s coming out (like StatusTypes, and ExpenseCategories) you need a delete trigger.

  5. If a table has nothing but "IU"s coming out (like Limits and Items) you need an insert/update trigger.

  6. If a table has a mix of both, you need both!

Figure 13: Spot the triggers

Figure 13 illustrates the three possibilities for triggers (delete, insert, or both).

Each "D" or "IU" will correspond to a fairly generic block of code in the trigger. Once we handle the DRI constraints in the first part of the trigger, the business rules can be enforced in the second part.

Generic Trigger Code

In our trigger walkthrough, we'll encounter three blocks of code that enforce the DRI:

Trigger Naming Convention

The trigger names adhere to a pretty simple naming convention that looks like this:

 tr_[table name]_[i]|[u]|[d]

All the triggers start with "tr_", followed by the name, and then a letter indicating what kind of action calls the trigger (insert, update, or delete). We were able to handle the insert and update cases in the same block of code. Some of the triggers are shown in Figure 14.

Another rule of thumb is that every FK should have a line item in the Insert/Update trigger. Also, every FK-PK relationship in the primary table should either cascade delete (Users.UserID->Limits.UserID) or roll the whole transaction back (Users.UserID->ExpenseReports).

Figure 14. Triggers in Data View

Trigger Summary

Table name Delete trigger Insert/Update trigger
ExpenseCategories tr_ExpenseCategories_d  
ExpenseReports tr_ExpenseReports_d tr_ExpenseReports_iu
Items   tr_Items_iu
Limits tr_Limits_iu
StatusTypes tr_StatusTypes_d  
Users tr_Users_d tr_Users_iu

tr_ExpenseCategories_d

We don't allow an ExpenseCategory to be deleted if any items or limits are currently referring to it. Notice the two generic SQL blocks that enforce the PK-FK relationship:

Create Trigger tr_ExpenseCategories_d
On dbo.ExpenseCategories
For Delete
As
   -- rollback if there are any Items that use this type
   if exists (select * from Items where CategoryID in
      (select CategoryID from deleted) )
   begin
      raiserror ('Can''t delete ExpenseCategories record(s) because of 
         FK Items.CategoryID', 16, 1)
      rollback tran
      return
   end

   if exists (select * from Limits where CategoryID in
      (select CategoryID from deleted) )
   begin
      raiserror ('Can''t delete ExpenseCategories record(s) because of 
         FK Limits.CategoryID', 16, 1)
      rollback tran
      return
   end 

tr_ExpenseReports_d

If an expense report is deleted, remove the related Items. The following code is an example of a cascading delete:

Create Trigger tr_ExpenseReports_d
On dbo.ExpenseReports
For Delete
As

   -- cascade the delete to the Items and tracklog table
   declare @counter int
   delete Items
   from Items, deleted
   where Items.ExpenseReportID = deleted.ExpenseReportID
   select @counter = @@ROWCOUNT

   if (@counter > 0)
      raiserror('%d Items rows were deleted due to delete in ExpenseReports table', 
               10, 1, @counter)

   

tr_ExpenseReports_iu

When a new expense report is added, make sure the UserID and StatusIDs are valid. The following are two examples of the use of the generic insert/update SQL:

Create Trigger tr_ExpenseReports_iu
On dbo.ExpenseReports
For Insert, Update
As
   if update(UserID)
      if exists (select * from inserted where inserted.UserID not in
         (select Users.UserID from Users) )
      begin
         raiserror ('New or updated ExpenseReport.UserID must exist.', 16, 1)
         rollback tran
         return
      end

   if update(StatusID)
      if exists (select * from inserted where inserted.StatusID not in
         (select StatusID from StatusTypes) )
      begin
         raiserror ('New or updated ExpenseReport.StatusID must exist.', 16, 1)
         rollback tran
         return
      end

tr_Items_iu

This is by far the largest trigger. The first two blocks of code ensure new items have a valid ExpenseReportID and are assigned to a valid CategoryID.

The next section of code enforces three business rules:

Before we bother checking the limits, the trigger continues only if the Amount column is being updated in this batch. Because of the way the rest of the logic is coded, we can only continue if a single expense report is being changed. Because there is only one expense report in the batch, we can look up the one and only user and store the ID in @UserID:

Create Trigger tr_Items_iu
On dbo.Items
For Insert, Update 
As

   if update(ExpenseReportID)
      if exists (select * from inserted where inserted.ExpenseReportID not in
         (select ExpenseReportID from ExpenseReports) )
      begin
         raiserror ('The Item.ExpenseReportID does not exist.', 16, 1)
         rollback tran
         return
      end

   if update(CategoryID)
      if exists (select * from inserted where inserted.CategoryID not in
         (select CategoryID from ExpenseCategories) )
      begin
         raiserror ('The Item.CategoryID does not exist.', 16, 1)
         rollback tran
         return
      end

   if update(Amount)
   begin
   
      -- The following code assumes only one unique ExpenseReport 
      -- (and therefore one user) is part of the transaction.
      -- Rollback if this is not the case.
      --
      declare @result int
      declare @UserID int
      
      select @result = count (distinct ExpenseReportID)
      from inserted
      if @result > 1
      begin
         raiserror ('Only one expense report in a batch can update the Amount column.',
                   16, 1)
         rollback tran
         return
      end
      
      -- Lookup the UserID for this batch
      --
      select @UserID = UserID
      from ExpenseReports er, inserted i
      where er.ExpenseReportID = i.ExpenseReportID

      -- Abort if this new ExpenseItem exceeds predefined individual limits
      --
      select @result = count(*)
      from inserted i, Limits l, ExpenseReports e
      where i.ExpenseReportID = e.ExpenseReportID
      and l.UserID = @UserID
      and Amount > Limit
      
      if @result >= 1
      begin
         raiserror ('Expense item exceeds limits in this category for this user', 
                  16, 1)
         rollback tran
         return
      end
      
      -- Abort if this ExpenseItem pushes the user over their designated total 
      -- ExpenseReport limit

      declare @total smallmoney
      select @total = sum(Items.Amount)
      from inserted i, Items
      where i.ExpenseReportID = Items.ExpenseReportID
      
      select @total = @total + sum(Amount)
      from inserted i
      
      select @result = count(*)
      from Users
      where UserID = @UserID
      and @total > Limit
      
      if @result >= 1
      begin
         raiserror ('Expense report exceeds spending limits for this user', 16, 1)
         rollback tran
         return
      end

      -- Abort if this ExpenseItem exceeds the global limits defined in the             -- ExpenseCategories table
      --
      select @result = count(*)
      from inserted i, ExpenseCategories ec
      where i.CategoryID = ec.CategoryID
      and Amount > Limit      
      if @result >= 1
      begin
         raiserror ('Item exceeds spending limits for this category.', 16, 1)
         rollback tran
         return
      end      
         
   end

tr_Limits_iu

The Limits table trigger ensures valid UserID and CategoryIDs for a new Limits record:

Create Trigger tr_Limits_iu
On dbo.Limits
For Insert, Update
As
   if update(UserID)
      if exists (select * from inserted where inserted.UserID not in
         (select Users.UserID from users) )
      begin
         raiserror ('No matching User found. Statement will be aborted.', 16, 1)
         rollback tran
         return
      end
      
   if update(CategoryID)
      if exists (select * from inserted where inserted.CategoryID not in
         (select ExpenseCategories.CategoryID from ExpenseCategories) )
      begin
         raiserror ('No matching ExpenseCategory found.', 16, 1)
         rollback tran
      end

tr_StatusTypes_d

This simple trigger makes sure status records aren't deleted if any expense reports are using that status type:

Create Trigger tr_StatusTypes_d
On dbo.StatusTypes
For Delete
As
   -- rollback if there are any FK ExpenseReports.StatusID 
   if exists (Select * from ExpenseReports where StatusID in 
      (select StatusID from deleted) )
   begin
      raiserror ('FK ExpenseReports.StatusID->StatusTypes.StatusID', 16, 1)
      rollback tran
      return
   end

tr_Users_d

The first SQL block rolls back the transaction if an attempt is made to delete a user who has expense reports logged in the system. The second SQL block doesn't allow the deletion of a manager. The employees under that manager must be removed first.

The third block is special case: It doesn't allow anyone to delete the administration user (UserID = 1).

If the first three conditions are met, the related limit records are deleted:

Create Trigger tr_Users_d
On dbo.Users
For Delete 
As

   -- rollback if this user has any expense reports
   if exists (select * from ExpenseReports where UserID in 
      (select UserID from deleted) )
   begin
      raiserror ('Can''t delete a User that has any Expense Reports', 16, 1)
      rollback tran
      return
   end

   -- rollback if this user is a manager
   if exists (select * from Users where ManagerID in 
      (select UserID from deleted) )
   begin
      raiserror('Can''t delete a User that is managing other employees.', 16, 1)
      rollback tran
      return
   end

   -- don't allow deletion of the "root" user
   if exists (select * from deleted where UserID=1)
   begin
      raiserror('Can''t remove the Admin user.', 16, 1)
      rollback tran
      return
   end

   -- cascade the delete to the limits table
   declare @counter int
   delete limits
   from limits, deleted
   where limits.UserID = deleted.UserID
   select @counter =@@ROWCOUNT

   if (@counter > 0) 
      raiserror('%d Limit rows were deleted due to delete in Users table', 
               10, 1, @counter)

tr_Users_iu

The last trigger in the group prevents new users with invalid ManagerIDs from being added to the system:

Create Trigger tr_Users_iu
On dbo.Users
For Insert, Update
As
   -- Don't bother checking for updated UserID; 
   -- It's an Identity column so it can't change.

   if update(ManagerID)
      if exists (select * from inserted where inserted.ManagerID not in
         (select Users.UserID from Users) )
      begin
         raiserror ('New or updated User.ManagerID must exist.', 16, 1)
         rollback tran
      end

Toss-up

One of our triggers enforces a rule that doesn't allow you to delete a user if they happen to be a manager.

We could have handled this case in a couple of ways:

They all have drawbacks. We decided to punt the whole issue and let the system administrators deal with the problem on a case-by-case basis.

Think about what this situation really means: A manager with maybe five people in her department leaves the company. You need to add the replacement manager, and then walk through all five users and change their manager in the drop-down combo box. Now you can delete the manager who walked. Not a big deal.

Other options would be to feed all deletes and inserts through stored procedures that would handle the dirty work. This doesn't work well for future applications that might write directly to the database, expecting triggers or DRI to handle the exceptions.

Testing the Triggers

What's the best way to test the triggers? Use the marked-up printout and mentally walk through each line that connects the table. Because the lines are essentially bi-directional, you have to come up with test cases that test both directions.

For example, when examining the relationship between Users and ExpenseReports, ask yourself these questions:

References

Teach Yourself Transact-SQL in 21 Days (McEwan & Soloman) from SAMS Publishing for trigger syntax and examples.

Inside Microsoft SQL Server 6.5 (Soukup) from Microsoft Press for the "alter table" syntax. See pps. 572-573.

Stored Procedures

The FMCorp database contains a small set of stored procedures that automate the handling of expense reports and users, and management of the database as a whole. They include the following.

Stored procedure name Contents Used by
sp_GetExpenseItems Returns all expense items, including the category of each, for a particular expense report. Web page:
ReportDetails.asp
sp_GetExpenseReport Returns expense report details, including the total amount and the user who submitted the report. Web page:
ReportDetails.asp
sp_GetUserID Returns the UserID that matches a given e-mail address. Component:
ExpenseReadDB
GetUserID
sp_GetMgrSummary Returns a list of expense reports for a manager's employees. Web page:
MgrReportList.cpp
sp_GetUserSummary Returns a list of expense reports submitted by a given user. Web page:
UserReportList.cpp
sp_NewExpenseItem Adds a new item to an expense report. Component:
ExpenseWriteDB
NewExpenseReport()
sp_NewExpenseReport Creates a new expense report and returns the ExpenseReportID. Component:
ExpenseWriteDB
AddExpenseItem()
sp_Login Validates an e-mail/password combination for login. Component:
ExpenseReadDB Login()
sp_NTLogin Validates a Windows NT domain and user name combination for login. Component:
ExpenseReadDB NTLogin()
sp_populate Used during the database creation to populate the database with fictitious employees and expense reports. Also populates the StatusTypes and ExpenseCategories tables. FMCorp.sql setup script

The developers decided to use stored procedures for these retrieval operations because of the performance benefit. Stored procedures are precompiled collections of SQL statements and control-of-flow language that execute very quickly. Stored procedures are stored within a database; can be executed with one call from an application; and accept user-declared variables, conditional execution, and other powerful programming features.

Conclusion

The best practices you can take away from this article include:

About the Author

Scott Stanfield is the president of Vertigo Software, Inc. Vertigo Software is a San Francisco Bay Area-based consulting firm that specializes in the design and development of Windows DNA applications and components. He can be reached at scott@vertigosoftware.com or on the Web at http://www.vertigosoftware.com/.

For More Information