Introduction
I know for a fact the bits and pieces of this article are scattered around this site as well as in other programming sites. What I'm going to try and do is bring them all together in a "from start to finish" solution.
Background
Building even a simple search engine for in-program data can be complicated at times; building an advanced search engine for the same in-program data where any or all of the parameters can be used can be a nightmare.
I would like to share my solution for this problem; however, bear in mind that I'm sure there is more than one way to do this. It's simply the most efficient way I've found (balancing speed and quickly implementing it).
Pre-Planning Stage
We have in existence a database for a tattoo shop manager that contains the following tables:
- Clients - holds name, address, city, state, date of birth, e-mail, phone numbers, sex, type of advertisement that led them to find the company.
- Jobs - holds job titles, type, client-ID assigned to job, artist-ID assigned to job, job date, job length, job status, etc.
Part of the shop manager program is a Client Manager which allows the user to search (or, filter is actually a more correct term) the clients in the database based on various criteria.
Everything in the image above is optional; it can be searched with all or none of the criteria.
Plan of attack
One way to implement this is once the user clicks the Search Clients button, loop through all the controls above and check each of their values and build an inline SQL Select
statement based on the values present.
This approach is fairly straightforward, and you never really have to leave your code. But there are some major set backs in my opinion.
- My current WinForm has 30+ input controls used for the search engine; looping through, casting, and checking each control is going to take some time, not to mention probably a couple hundred lines of code to implement.
- The search looks at multiple tables; having your code creating the proper joins can potentially be bad.
- Adding or removing criteria have the potential to require massive rewriting of the SQL
Select
statement producing code.
Another way to implement this is to write Stored Procedures that cover every conceivable combination or search criteria.
This, of course, is completely impractical, and makes even less sense than the previous option, but I thought it should be mentioned that it is possible this way.
Some simple reasons why not to use this option:
- The time it would take to think up and then write each Stored Procedure, you'd be a couple years older.
- Developers implementing the search engine would need a binder to hold the list of Stored Procedures for reference.
Now to the third way of implementing this. To me, this is sort of a mid-ground between the first two options. Using one Stored Procedure, we supply all the possible search parameters, but we make each of them optional so that the Stored Procedure will execute with some, all, or none of the parameters. Using a strongly typed TableAdapter
, we use the previous Stored Procedure to fill our search results dataset.
Implementation - Step 1: Writing the Stored Procedure
The parameters section:
CREATE PROCEDURE dbo.SearchClients
(
@ClientFirstName varchar(20) = null,
@ClientLastName varchar(20) = null,
@ClientHasTattoo bit = null,
@JobFinished bit = null,
@JobTypeID smallint = null,
@ArtistID Smallint = null,
@JobTotalHoursMinimum float = null,
@JobTotalHoursMaximum float = null,
@AdvertisingID smallint = null,
@ClientDOBMinimum datetime = null,
@ClientDOBMaximum datetime = null,
@ClientStreetAddress varchar(75) = null,
@ClientCity varchar(20) = null,
@ClientState varchar(2) = null,
@ClientZip varchar(10) = null,
@ClientHomePhone varchar(14) = null,
@ClientWorkPhone varchar(14) = null,
@ClientEmail varchar(50) = null,
@ClientSex bit = null,
@JobDateMinimum datetime = null,
@JobDateMaximum datetime = null,
@JobTitle varchar(50) = null
)
Couple of things to note here:
- Every parameter is set to
null
; this gives it a default value that the Stored Procedure can use when we fail to provide our own.
- You'll notice every text field is a
varchar
. I'll explain why in a moment; for now, I just wanted to bring it to your attention.
The Select
sections:
AS
IF @JobFinished IS NULL AND
@JobTypeID IS NULL AND
@ArtistID IS NULL AND
@JobTotalHoursMaximum IS NULL AND
@JobTotalHoursMinimum IS NULL AND
@JobDateMinimum IS NULL AND
@JobDateMaximum IS NULL AND
@JobTitle IS NULL
BEGIN
SELECT DISTINCT
ClientID, ClientFirstName, ClientLastName, ClientStreetAddress,
ClientCity, ClientState, ClientZip, ClientHomePhone,
ClientWorkPhone, ClientEmail, ClientDOB, ClientSex, ClientHasTattoo, AdvertisingID
FROM Clients
WHERE
(ClientFirstName LIKE ISNULL(@ClientFirstName, ClientFirstName) + '%') AND
(ClientLastName LIKE ISNULL(@ClientLastName, ClientLastName) + '%') AND
(ClientHasTattoo = ISNULL(@ClientHasTattoo, ClientHasTattoo)) AND
(AdvertisingID = ISNULL(@AdvertisingID, AdvertisingID)) AND
(ClientDOB >= ISNULL(@ClientDOBMinimum, ClientDOB)) AND
(ClientDOB <= ISNULL(@ClientDOBMaximum, ClientDOB)) AND
(ClientStreetAddress LIKE ISNULL(@ClientStreetAddress, ClientStreetAddress) + '%') AND
(ClientCity LIKE ISNULL(@ClientCity, ClientCity) + '%') AND
(ClientState LIKE ISNULL(@ClientState, ClientState) + '%') AND
(ClientZip LIKE ISNULL(@ClientZip, ClientZip) + '%') AND
(ClientHomePhone LIKE ISNULL(@ClientHomePhone, ClientHomePhone) + '%') AND
(ClientWorkPhone LIKE ISNULL(@ClientWorkPhone, ClientWorkPhone) + '%') AND
(ClientEmail LIKE ISNULL(@ClientEmail, ClientEmail) + '%') AND
(ClientSex = ISNULL(@ClientSex, ClientSex))
ORDER BY ClientID, ClientLastName, ClientFirstName
END
ELSE
BEGIN
SELECT DISTINCT
Clients.ClientID, Clients.ClientFirstName, Clients.ClientLastName,
Clients.ClientStreetAddress, Clients.ClientCity,
Clients.ClientState, Clients.ClientZip, Clients.ClientHomePhone,
Clients.ClientWorkPhone, Clients.ClientEmail,
Clients.ClientDOB, Clients.ClientSex,
Clients.ClientHasTattoo, Clients.AdvertisingID
FROM Job RIGHT OUTER JOIN
Clients ON Job.ClientID = Clients.ClientID
WHERE
(Clients.ClientFirstName LIKE ISNULL(@ClientFirstName, Clients.ClientFirstName) + '%') AND
(Clients.ClientLastName LIKE ISNULL(@ClientLastName, Clients.ClientLastName) + '%') AND
(Clients.ClientHasTattoo = ISNULL(@ClientHasTattoo, Clients.ClientHasTattoo)) AND
(Job.JobFinished = ISNULL(@JobFinished, Job.JobFinished)) AND
(Job.ArtistID = ISNULL(@ArtistID, Job.ArtistID)) AND
(Job.JobTotalHours >= ISNULL(@JobTotalHoursMinimum, Job.JobTotalHours)) AND
(Job.JobTotalHours <= ISNULL(@JobTotalHoursMaximum, Job.JobTotalHours)) AND
(Job.JobTypeID = ISNULL(@JobTypeID, Job.JobTypeID)) AND
(Clients.AdvertisingID = ISNULL(@AdvertisingID, Clients.AdvertisingID)) AND
(Clients.ClientDOB >= ISNULL(@ClientDOBMinimum, Clients.ClientDOB)) AND
(Clients.ClientDOB <= ISNULL(@ClientDOBMaximum, Clients.ClientDOB)) AND
(Clients.ClientStreetAddress LIKE ISNULL(@ClientStreetAddress,
Clients.ClientStreetAddress) + '%') AND
(Clients.ClientCity LIKE ISNULL(@ClientCity, Clients.ClientCity) + '%') AND
(Clients.ClientState LIKE ISNULL(@ClientState, Clients.ClientState) + '%') AND
(Clients.ClientZip LIKE ISNULL(@ClientZip, Clients.ClientZip) + '%') AND
(Clients.ClientHomePhone LIKE ISNULL(@ClientHomePhone, Clients.ClientHomePhone) + '%') AND
(Clients.ClientWorkPhone LIKE ISNULL(@ClientWorkPhone, Clients.ClientWorkPhone) + '%') AND
(Clients.ClientEmail LIKE ISNULL(@ClientEmail, Clients.ClientEmail) + '%') AND
(Clients.ClientSex = ISNULL(@ClientSex, Clients.ClientSex)) AND
(Job.JobDate >= ISNULL(@JobDateMinimum, Job.JobDate)) AND
(Job.JobDate <= ISNULL(@JobDateMaximum, Job.JobDate)) AND
(Job.JobTitle LIKE ISNULL(@JobTitle, Job.JobTitle) + '%')
ORDER BY Clients.ClientID, Clients.ClientLastName, Clients.ClientFirstName
END
Some things to note here:
- You'll notice a rather lengthy
IF
statement.
IF @JobFinished IS NULL AND
@JobTypeID IS NULL AND
@ArtistID IS NULL AND
@JobTotalHoursMaximum IS NULL AND
@JobTotalHoursMinimum IS NULL AND
@JobDateMinimum IS NULL AND
@JobDateMaximum IS NULL AND
@JobTitle IS NULL
This is checking to see if no parameters that apply to external tables from the client's table are provided; then we can use the slightly more simplified Select
statement:
SELECT DISTINCT
ClientID, ClientFirstName, ClientLastName, ClientStreetAddress,
ClientCity, ClientState, ClientZip, ClientHomePhone,
ClientWorkPhone, ClientEmail, ClientDOB, ClientSex, ClientHasTattoo, AdvertisingID
FROM Clients
WHERE
(ClientFirstName LIKE ISNULL(@ClientFirstName, ClientFirstName) + '%') AND
(ClientLastName LIKE ISNULL(@ClientLastName, ClientLastName) + '%') AND
(ClientHasTattoo = ISNULL(@ClientHasTattoo, ClientHasTattoo)) AND
(AdvertisingID = ISNULL(@AdvertisingID, AdvertisingID)) AND
(ClientDOB >= ISNULL(@ClientDOBMinimum, ClientDOB)) AND
(ClientDOB <= ISNULL(@ClientDOBMaximum, ClientDOB)) AND
(ClientStreetAddress LIKE ISNULL(@ClientStreetAddress,
ClientStreetAddress) + '%') AND
(ClientCity LIKE ISNULL(@ClientCity, ClientCity) + '%') AND
(ClientState LIKE ISNULL(@ClientState, ClientState) + '%') AND
(ClientZip LIKE ISNULL(@ClientZip, ClientZip) + '%') AND
(ClientHomePhone LIKE ISNULL(@ClientHomePhone, ClientHomePhone) + '%') AND
(ClientWorkPhone LIKE ISNULL(@ClientWorkPhone, ClientWorkPhone) + '%') AND
(ClientEmail LIKE ISNULL(@ClientEmail, ClientEmail) + '%') AND
(ClientSex = ISNULL(@ClientSex, ClientSex))
ORDER BY ClientID, ClientLastName, ClientFirstName
Or, if one of the parameters are provided to use the more advanced Select
statement:
SELECT DISTINCT
Clients.ClientID, Clients.ClientFirstName, Clients.ClientLastName,
Clients.ClientStreetAddress, Clients.ClientCity,
Clients.ClientState, Clients.ClientZip,
Clients.ClientHomePhone, Clients.ClientWorkPhone, Clients.ClientEmail,
Clients.ClientDOB, Clients.ClientSex,
Clients.ClientHasTattoo, Clients.AdvertisingID
FROM Job RIGHT OUTER JOIN
Clients ON Job.ClientID = Clients.ClientID
WHERE
(Clients.ClientFirstName LIKE ISNULL(@ClientFirstName,
Clients.ClientFirstName) + '%') AND
(Clients.ClientLastName LIKE ISNULL(@ClientLastName,
Clients.ClientLastName) + '%') AND
(Clients.ClientHasTattoo =
ISNULL(@ClientHasTattoo, Clients.ClientHasTattoo)) AND
(Job.JobFinished = ISNULL(@JobFinished, Job.JobFinished)) AND
(Job.ArtistID = ISNULL(@ArtistID, Job.ArtistID)) AND
(Job.JobTotalHours >= ISNULL(@JobTotalHoursMinimum, Job.JobTotalHours)) AND
(Job.JobTotalHours <= ISNULL(@JobTotalHoursMaximum, Job.JobTotalHours)) AND
(Job.JobTypeID = ISNULL(@JobTypeID, Job.JobTypeID)) AND
(Clients.AdvertisingID = ISNULL(@AdvertisingID, Clients.AdvertisingID)) AND
(Clients.ClientDOB >= ISNULL(@ClientDOBMinimum, Clients.ClientDOB)) AND
(Clients.ClientDOB <= ISNULL(@ClientDOBMaximum, Clients.ClientDOB)) AND
(Clients.ClientStreetAddress LIKE ISNULL(@ClientStreetAddress,
Clients.ClientStreetAddress) + '%') AND
(Clients.ClientCity LIKE ISNULL(@ClientCity, Clients.ClientCity) + '%') AND
(Clients.ClientState LIKE ISNULL(@ClientState,
Clients.ClientState) + '%') AND
(Clients.ClientZip LIKE ISNULL(@ClientZip, Clients.ClientZip) + '%') AND
(Clients.ClientHomePhone LIKE ISNULL(
@ClientHomePhone, Clients.ClientHomePhone) + '%') AND
(Clients.ClientWorkPhone LIKE ISNULL(
@ClientWorkPhone, Clients.ClientWorkPhone) + '%') AND
(Clients.ClientEmail LIKE ISNULL(@ClientEmail,
Clients.ClientEmail) + '%') AND
(Clients.ClientSex = ISNULL(@ClientSex, Clients.ClientSex)) AND
(Job.JobDate >= ISNULL(@JobDateMinimum, Job.JobDate)) AND
(Job.JobDate <= ISNULL(@JobDateMaximum, Job.JobDate)) AND
(Job.JobTitle LIKE ISNULL(@JobTitle, Job.JobTitle) + '%')
ORDER BY Clients.ClientID, Clients.ClientLastName, Clients.ClientFirstName
- You'll notice the many
ISNULL
s in the Select statements above. They are, aside from setting the values in the parameter section to null
, the most important part of this procedure working.
ISNULL(@JobFinished, Job.JobFinished))
If @JobFinished = null
(we're using the default value), then the current value of the current record's Job.JobFinished
is used instead.
- Remember those
varchar
s back in the parameters section? Well, here's why we used them.
(Clients.ClientFirstName LIKE ISNULL(@ClientFirstName, Clients.ClientFirstName) + '%')
Using varchar
allows us to do partial word searches since trailing whitespaces are automatically trimmed off of varchar
strings (not so with nchar
an char
). Combine that with +
'%
', and you can now enter the first part of a word and it will still pull all the words that start with those letters. For instance:
If we pass 'jo' in as @ClientFirstName
, the procedure would select "Joe", "John", "Joseph", etc.
- One last thing to notice is the
DISTINCT
keyword before the select statements this ensures you don't get a long list of repeating clients.
Implementation - Step 2: Filling a Strongly Typed Dataset
Using Visual Studio's Dataset Designer, we create a strongly typed dataset with our Stored Procedure. The designer automatically creates a Fill()
and a GetData()
method based on the parameter's of the Stored Procedure.
Now, it's just a matter of inputting our values into the Fill()
method.
Well, here it is in action!
this.searchClientsTableAdapter.Fill(
this.tattooDataSet.SearchClients,
this.NewClientFirstNameTextBox.Text == "" ? null : this.ClientFirstNameTextBox.Text,
this.NewClientLastNameTextBox.Text == "" ? null : this.NewClientLastNameTextBox.Text,
this.UndecidedRadioButton.Checked ? (bool?)null :
(this.HasTattooRadioButton.Checked ? true : false),
!this.UseJobIsCheckBox.Checked ? (bool?)null :
(this.BothRadioButton.Checked ? (bool?)null :
(this.CompletedRadioButton.Checked ? true : false)),
!this.UseJobTypeCheckBox.Checked ? (short?)null :
(short)this.JobTypeComboBox.SelectedValue,
!this.SearchArtistCheckBox.Checked ? (short?)null :
(short)this.ArtistsComboBox.SelectedValue,
!this.MinimumTimeCheckBox.Checked ? (double?)null :
double.Parse(this.MinimumHourNumericUpDown.Value.ToString()),
!this.MaximumTimeCheckBox.Checked ? (double?)null :
double.Parse(this.MaximumHourNumericUpDown.Value.ToString()),
!this.UseAdvertisementsCheckBox.Checked ? (short?)null :
(short)this.AdvertismentsComboBox.SelectedValue,
!this.BornAfterCheckBox.Checked ? (DateTime?)null :
this.MinimumDOBDateTimePicker.Value,
!this.BornBeforeCheckBox.Checked ? (DateTime?)null :
this.MaximumDOBDateTimePicker.Value,
this.NewClientAddressTextBox.Text == "" ? null :
this.NewClientAddressTextBox.Text,
this.NewClientCityTextBox.Text == "" ? null :
this.NewClientCityTextBox.Text,
this.NewClientProvenceTextBox.Text == "" ? null :
this.NewClientProvenceTextBox.Text,
this.NewClientPostalCodeTextBox.Text == "" ? null :
this.NewClientPostalCodeTextBox.Text,
this.NewClientHomePhoneTextBox1.Text == "" ? null :
this.NewClientHomePhoneTextBox1.Text +
(((this.NewClientHomePhoneTextBox1.TextLength ==
this.NewClientHomePhoneTextBox1.MaxLength) &&
(this.NewClientHomePhoneTextBox2.TextLength > 0)) ?
("-" + this.NewClientHomePhoneTextBox2.Text) : ("")) +
(((this.NewClientHomePhoneTextBox2.TextLength ==
this.NewClientHomePhoneTextBox2.MaxLength) &&
(this.NewClientHomePhoneTextBox3.TextLength > 0)) ?
("-" + this.NewClientHomePhoneTextBox3.Text) : ("")),
this.NewClientWorkPhoneTextBox1.Text == "" ? null :
this.NewClientWorkPhoneTextBox1.Text +
(((this.NewClientWorkPhoneTextBox1.TextLength ==
this.NewClientWorkPhoneTextBox1.MaxLength) &&
(this.NewClientWorkPhoneTextBox2.TextLength > 0)) ?
("-" + this.NewClientWorkPhoneTextBox2.Text) : ("")) +
(((this.NewClientWorkPhoneTextBox2.TextLength ==
this.NewClientWorkPhoneTextBox2.MaxLength) &&
(this.NewClientWorkPhoneTextBox3.TextLength > 0)) ?
("-" + this.NewClientWorkPhoneTextBox3.Text) : ("")),
this.NewClientEmailTextBox.Text == "" ? null :
this.NewClientEmailTextBox.Text,
this.BothSexRadioButton.Checked ? (bool?)null :
(this.MaleRadioButton.Checked ? true : false),
!this.ScheduledAfterCheckBox.Checked ? (DateTime?)null :
this.JobMinimumDateTimePicker.Value,
!this.ScheduledBeforeCheckBox.Checked ? (DateTime?)null :
this.JobMaximumDateTimePicker.Value,
!this.UseJobTitleCheckBox.Checked ? null : this.JobTitleSearchTextBox.Text);
I've wrapped each parameter on its own line for readability, but it's still just one function call. Adding or removing search options is as simple as adding or removing a parameter.
There two really nifty things I want to point out about the Fill()
method, and they both involve one single character: ?.
- ? is the Conditional Operator, and has been around in one form or another for years (I can remember using it back when I was coding in plain old C).
If you think of if...else
and select...case
statements as the foot soldiers in your programming arsenal, they get the job done, but can sometimes be brutal and messy in their tactics, and then ?
is part of your special forces:
- They can go places others can't (try putting an
if
statement as a parameter in a method call).
- They provide a cleaner, more efficient, more concise solution.
this.NewClientCityTextBox.Text == "" ? null : this.NewClientCityTextBox.Text,
The first part this.NewClientCityTextBox.Text == ""
is the conditional part; if it evaluates to be true, then the first value after the ?
, or in this case, null
is used; otherwise, the value following the :
, or in this case, this.NewClientCityTextBox.Text
is used.
Conditional statements can be nested, and are evaluated in a right to left fashion.
!this.UseJobIsCheckBox.Checked ? (bool?)null :
(this.BothRadioButton.Checked ? (bool?)null :
(this.CompletedRadioButton.Checked ? true : false)),
- First,
(this.CompletedRadioButton.Checked ? true : false)
evaluates to either be true
or false
.
- Second,
this.BothRadioButton.Checked ? (bool?)null :
evaluates to be either null
or the result from the previous condition.
- Finally,
!this.UseJobIsCheckBox.Checked ? (bool?)null :
evaluates to be either null
or the result from the previous condition.
If you look back at the picture above, you'll notice the Job Is check box is checked, and to its right are three radio buttons that are actually contained in their own panel to group them accordingly.
Now looking at the previous nested conditional statements in plain English, this is what we're saying:
- If the Completed radio button is checked, then it's true; if not, we assume that the Incompleted radio button is checked, passing
false
to the next step.
- Now if either radio button is checked, then we know that the Incompleted radio button is not checked, so we pass null to the next step; if not, then we know that the Incompleted radio button is definitely checked, so we pass
false
on to the next step.
- Finally, if the Job Is checkbox is not checked, then that part of the search is disabled, and regardless of what the previous values are,
true
, false
, or null
, it's automatically null
; if not, it evaluates to the value of the previous statement.
- ? In .NET 2.0, in addition to its previously mentioned duties as the conditional operator, it is also the new nullable operator. This makes anyone who interacts with databases a very happy person!
Certain data types in C#, VB.NET, C, Java, etc., and .NET framework types such as bool, for example, don't have a null state that can be assigned null.
bool IsThisNull = null;
won't compile. In a database, a boolean can be either true, false, or null. The problem in the past has been bridging that null gap between the source code nulls and the database nulls. The Nullable operator does this quite beautifully.
bool? IsThisNull = null;
This compiles and works fine. IsThisNull
still works just like a regular bool, with the added bonus that it can be null for passing value to a database Stored Procedure or inline SQL statement.
The .NET 2.0 framework, in the same fashion that they provide us with overloaded operators like ++
, --
, =+
, also provides an overloaded nullable operator ??
.
bool? IsThisNull = null;
bool RegularBool = IsThisNull ?? false;
RegularBool
is assigned whatever true or false value IsThisNull
holds, unless IsThisNull
is null
- then RegularBool
is false
.
We're not done with the fun yet boys and girls. Explicit conversions (or boxing):
!this.UseAdvertisementsCheckBox.Checked ? (short?)null :
(short)this.AdvertismentsComboBox.SelectedValue,
Notice the (short?)
? This handy little ?
casts our plain old null value into a nullable short type with a null value, so our Stored Procedure gets the null value it's looking for, and our type safe C# method gets its short
type parameter it expects.
Everyone gets what they want, and in the spirit of MPATQFTHG: "and there was much rejoicing".
Reviewing
Step 1 - Write our Stored Procedure remembering to set each parameter to null as its default value; then use ISNULL
statements to ensure the correct values are passed in our Select
statements.
Step 2 - Create our strongly typed Dataset with a strongly typed TableAdapter in Visual Studio.
Step 3 - Call the generated Fill()
method using the ?
conditional operator as well as the ?
Nullable operator to filter and pass the correct value to the parameter and to cast the correct types, respectively.
Author's Corner
I probably won't be putting a code sample up to download for the simple fact that this article is not meant to give you a working solution but rather a good step by step plan to creating your own working solution. Besides, all that you need to know is in the article in the <pre></pre> blocks.
In the future, I'll throw together a demo program for you to play around with; at the moment, it's used in a rather massive software suite being developed, and no one here is going to want to buy the whole farm just to find out how the tractor runs.
I always welcome comments, questions, and even complaints, so don't be shy.
History
- March 24, 2006 - First edition.
- March 29, 2006 - Made some minor changes; someone pointed out that I was calling it a DataAdapter and not a TableAdapter; I'm so used to using the DataAdapters in 1.1 that I completely missed it.