SQL Server application using classes for data access
Introduction
This article describes how to create a Windows Desktop application using VB.NET programming language and SQL-Server database. The intended audience are developers with no direction for working with SQL-Server database operations or developers currently utilizing TableAdapter or DataAdapter methods to work with SQL-Server databases.
The code presented will provide all the functionality needed in most solutions while some of the functionality may not be robust for each developer reading this which was done intentionally to provide a base to work from rather than a monolithic solution that needs reverse engineering. If familiar with Entity Framework then the base is similar to Entity Framework but without some of the niceties e.g. Entity Framework core opens connections as needed, built in save methods and a good deal more. Learning the basics here can assist in taking the next step to Entity Framework.
Building the solution
- Requires Visual Studio 2015 or higher.
- Requires an instance of SQL-Server installed or SQL-Express edition installed.
- Run script.sql in SplashScreenIteratorDemo under the folder DataClasses. Make sure to change the server name from KARENS-PC to the name of your SQL-Server instance or to .\SQLEXPRESS.
Description
A modified Microsoft NorthWind database (specifically for customer information) is used to
demonstrate interacting with data in the sample application.
Overview of the application
Display customer information within a DataGridView with the ability to view, add, edit and remove customers and related tables by code or cascading rules within the database. All data retrieved from the backend database are stored in class objects at runtime were the classes are defined in a separate class project which allows a developer to reuse these classes in other solutions.
There are several bases classes housed in a separate class project responsible for handling runtime exceptions. The exception class is inherited in the second class which is responsible for providing a connection string to the database used within the application. Both the exception and connection classes have protected properties which can be modified in the class (DataOperations class in the application project) which accesses data but the caller of the data class may only read the properties which has been done do there is no possible way the caller can causes issues at the presentation level. All data operations are performed in the data layer with the presentation layer calling the data layer.
Custom components
Since the classes which present information within a DataGridView do not have the capability to sort as when a DataTable is the DataSource of a DataGridView a custom BindingList is used. Besides providing the ability to sort the component provides the ability to filter data. Filtering uses System.Linq.Dynamic functionality which presents an issue such as performing case insensitive filtering e.g. StringComparison.CurrentCultureIgnoreCase can not be resolved so a property was added to the Customer class which allows the dynamic filtering to work as the added property is compliable at runtime.
The second component, a custom BindingSource which inherits the native BindingSource and provides methods to work with the underlying Customer list.
Public Class CustomerBindingSource
Inherits BindingSource
Public Sub New(container As System.ComponentModel.IContainer)
MyBase.New(container)
End Sub
Public Sub New()
MyBase.New()
End Sub
Public Sub New(datasource As Object, datamember As String)
MyBase.New(datasource, datamember)
End Sub
''' <summary>
''' Return the Customer list
''' </summary>
''' <returns></returns>
Public Function Customers As List(Of Customer)
return CType(DataSource, List(Of Customer))
End Function
''' <summary>
''' Indicator that there is a current object.
''' </summary>
''' <returns></returns>
Public Function CurrentIsValid As Boolean
Return Current IsNot nothing
End Function
''' <summary>
''' Returns the current customer object
''' </summary>
''' <returns></returns>
Public Function CurrentCustomer() As Customer
Return CType(Current, Customer)
End Function
Public sub UpdateCurrentCustomer(pCustomer as Customer)
CurrentCustomer().CompanyName = pCustomer.CompanyName
CurrentCustomer().ContactFirstName = pCustomer.ContactFirstName
CurrentCustomer().ContactLastName = pCustomer.ContactLastName
CurrentCustomer().ContactTitle = pCustomer.ContactTitle
CurrentCustomer().ContactTypeIdentifier = pCustomer.ContactTypeIdentifier
CurrentCustomer().ContactIdentifier = pCustomer.ContactIdentifier
CurrentCustomer().PhoneNumber = pCustomer.PhoneNumber
CurrentCustomer().PhoneTypeDescription = pCustomer.PhoneTypeDescription
CurrentCustomer().PhoneTypeIdenitfier = pCustomer.PhoneTypeIdenitfier
CurrentCustomer().DevicesIdentifier = pCustomer.DevicesIdentifier
CurrentCustomer().Street = pCustomer.Street
CurrentCustomer().City = pCustomer.City
CurrentCustomer().PostalCode = pCustomer.PostalCode
CurrentCustomer().CountryIdentifier = pCustomer.CountryIdentifier
CurrentCustomer().CountryName = pCustomer.CountryName
CurrentCustomer().Active = pCustomer.Active
End sub
''' <summary>
''' Indicates if the current customer has a status of active
''' </summary>
''' <returns></returns>
Public Function CurrentIsActive As Boolean
Return CurrentCustomer().Active
End Function
''' <summary>
''' Returns the current primary key for the customer
''' </summary>
''' <returns></returns>
public Function CurrentIdentifier as Integer
Return CurrentCustomer().CustomerIdentifier
End Function
''' <summary>
''' Returns the current company name
''' </summary>
''' <returns></returns>
public Function CurrentCompanyName as String
Return CurrentCustomer().CompanyName
End Function
''' <summary>
''' Returns the current contact name
''' </summary>
''' <returns></returns>
public Function CurrentContactName as String
Return CurrentCustomer().ContactName
End Function
Public Function ContactFirstName As String
Return CurrentCustomer().ContactFirstName
End Function
Public Function ContactLastName As string
Return CurrentCustomer().ContactLastName
End Function
''' <summary>
''' Returns the contact type key
''' </summary>
''' <returns></returns>
public Function CurrentContactTypeIdentifier as Integer
Return CurrentCustomer().ContactTypeIdentifier
End Function
''' <summary>
''' Returns the phone type key
''' </summary>
''' <returns></returns>
public Function CurrentPhoneTypeIdentifier as Integer
Return CurrentCustomer().PhoneTypeIdenitfier
End Function
End Class
Instead of the need to type cast the current property from object to customer we can use a method in the custom BindingSource.
_bsCustomers.CurrentCustomer()
Prior to accessing the current property it’s prudent to check if current object is valid so another method provides easy access.
_bsCustomers.CurrentIsValid()
To update the current property from changed data in a modal form.
_bsCustomers.UpdateCurrentCustomer(currentCustomer)
The alternates are type cast objects again and again or create language extension methods e.g. (which must resides in a code module).
<Runtime.CompilerServices.Extension> _
Public Function CurrentCustomer(pBindingSource As BindingSource) As Customer
Return CType(pBindingSource.Current, Customer)
End Function
Retrieving data
Reference tables are read in using several SELECT statements setup as follows.
Dim selectStatements As String =
<SQL>
SELECT ContactTypeIdentifier,ContactTitle FROM dbo.ContactType;
SELECT PhoneTypeIdenitfier,PhoneTypeDescription FROM dbo.PhoneType;
SELECT id,CountryName FROM dbo.Countries
</SQL>.Value
Note the use of XML literals and semi-colons to separate each SELECT statement. Each SELECT statement is read in via SqlClient DataReader using NextResult method to move to the next SELECT statement.
Customer data uses one SELECT statement which was created in SQL-Server Management Studio to ensure the proper data would be returned. If SQL-Server Management Studio is not available a developer can create a text file in their project, change the file extension from .txt to .sql and write SQL statements. Bonus if using Visual Studio 2017 which provides Intellisense for database operations.
The following SELECT statement uses XML literals as with the reference tables mentioned above.
Private ReadOnly Property CustomerSelectStatement As String
get
Return _
<SQL>
SELECT Cust.CustomerIdentifier ,
Cust.CompanyName ,
Contact.FirstName + ' ' + Contact.LastName AS ContactName ,
Contact.FirstName ,
Contact.LastName ,
Cust.ContactTypeIdentifier,
ContactType.ContactTitle ,
Contact.ContactIdentifier ,
Devices.PhoneNumber ,
PhoneType.PhoneTypeDescription ,
Devices.PhoneTypeIdenitfier,
Cust.Street ,
Cust.City ,
Cust.PostalCode ,
Countries.CountryName ,
Countries.id,
Cust.ModifiedDate,
Devices.Active ,
Devices.Identifier
FROM Customers AS Cust
INNER JOIN ContactType ON Cust.ContactTypeIdentifier = ContactType.ContactTypeIdentifier
INNER JOIN Countries ON Cust.CountryIdentfier = Countries.id
INNER JOIN Contact ON Cust.ContactIdentifier = Contact.ContactIdentifier
INNER JOIN ContactContactDevices AS Devices ON Contact.ContactIdentifier = Devices.ContactIdentifier
INNER JOIN PhoneType ON Devices.PhoneTypeIdenitfier = PhoneType.PhoneTypeIdenitfier
WHERE (Devices.Active = 1 AND PhoneType.PhoneTypeIdenitfier = 3
)
ORDER BY Cust.CompanyName;
</SQL>.Value
End get
End Property
The query is executed in a function utilizing an Iterator which returns IEnumerable(Of Customer) using a DataReader coupled with Yield statement. This occurs when the application starts and displays results in a splash screen. There is a short delay in the read operation as there are 102 records and without the delay everything would happen too fast to notice. The idea here is the user of the application sees progress as company names are displayed in the splash screen. The proper use is not for one hundred or several hundred records but many thousands of records or perhaps a slow connection to the backend database.
Special care is needed if an exception is thrown while in the iterator method since the splash screen runs in a separate thread a delegate is required to close the splash screen in this case.
DataGridView setup
For each customer field/property which is displayed in the DataGridView a column needs to be created and the DataPropertyName set. The default behavior of generating columns is not performed the following line stops auto generation of columns in form load event.
Modifying data
Both adding new customers and editing customers is performed in a modal dialog rather than directly in the DataGridView. Validation is performed in the modal dialog for ensuring required fields are entered. If there are exceptions thrown during add or update operations raised from code or the backend database the insert or update are rolled back via a transaction object and by using a base exception class exceptions can be displayed to the user without halting the application.
Note that when viewing adding and update methods for the novice/beginner developer may think there is a lot of code vs working with Table or Data adapters yet there is freedom to change how a operation performs rather than rely on what goes behind a Table or Data adapter.
For both add and updates several tables are affected as the version of NorthWind uses related tables for contact details along with the base customer table.
Inserting
For inserts, one reference table is inserted, the new primary key is returned and used for the next insert then used in the last insert.
Public Function AddNewCustomer(pCustomer As customer) As Boolean
mHasException = False
dim contactInsertStatement =
<SQL>
INSERT INTO dbo.Contact
(FirstName
,LastName)
VALUES
(@FirstName
,@LastName);
SELECT CAST(scope_identity() AS int);
</SQL>.Value
Dim contactDevicesInsertStatement =
<SQL>
INSERT INTO dbo.ContactContactDevices
(ContactIdentifier
,PhoneTypeIdenitfier
,PhoneNumber
,Active)
VALUES
(@ContactIdentifier
,@PhoneTypeIdenitfier
,@PhoneNumber
,@ACTIVE);
SELECT CAST(scope_identity() AS int);
</SQL>.Value
Dim customerInsertStatement =
<SQL>
INSERT INTO dbo.Customers
(CompanyName
,ContactName
,ContactIdentifier
,ContactTypeIdentifier
,Street
,City
,PostalCode
,CountryIdentfier
,Phone
,ModifiedDate)
VALUES
(@CompanyName
,@ContactName
,@ContactIdentifier
,@ContactTypeIdentifier
,@Street
,@City
,@PostalCode
,@CountryIdentfier
,@Phone
,@ModifiedDate);
SELECT CAST(scope_identity() AS int);
</SQL>.Value
Dim contactIdentifier As Integer = 0
Dim contactDeviceIdentifier as Integer = 0
Using cn As New SqlConnection With{.ConnectionString = ConnectionString}
cn.Open()
Dim sqlTran As SqlTransaction = cn.BeginTransaction()
Using cmd As New SqlCommand With
{
.Connection = cn,
.CommandText = contactInsertStatement,
.Transaction = sqlTran
}
Try
cmd.Parameters.AddWithValue("@FirstName",pCustomer.ContactFirstName)
cmd.Parameters.AddWithValue("@LastName",pCustomer.ContactLastName)
contactIdentifier = CInt(cmd.ExecuteScalar())
cmd.CommandText = contactDevicesInsertStatement
cmd.Parameters.Clear()
cmd.Parameters.AddWithValue("@ContactIdentifier",contactIdentifier)
cmd.Parameters.AddWithValue("@PhoneTypeIdenitfier",pCustomer.PhoneTypeIdenitfier)
cmd.Parameters.AddWithValue("@PhoneNumber",pCustomer.PhoneNumber)
cmd.Parameters.AddWithValue("@Active",True)
contactDeviceIdentifier = CInt(cmd.ExecuteScalar())
cmd.CommandText = customerInsertStatement
cmd.Parameters.Clear()
cmd.Parameters.AddWithValue("@CompanyName",pCustomer.CompanyName)
cmd.Parameters.AddWithValue("@ContactName",pCustomer.ContactName)
cmd.Parameters.AddWithValue("@ContactIdentifier",contactIdentifier)
cmd.Parameters.AddWithValue("@ContactTypeIdentifier",pCustomer.ContactTypeIdentifier)
cmd.Parameters.AddWithValue("@Street",pCustomer.Street)
cmd.Parameters.AddWithValue("@City",pCustomer.City)
cmd.Parameters.AddWithValue("@PostalCode",pCustomer.PostalCode)
cmd.Parameters.AddWithValue("@Phone",pCustomer.PhoneNumber)
cmd.Parameters.AddWithValue("@CountryIdentfier",pCustomer.CountryIdentifier)
cmd.Parameters.AddWithValue("@ModifiedDate",Now)
pCustomer.CustomerIdentifier = CInt(cmd.ExecuteScalar())
pCustomer.ContactIdentifier = contactIdentifier
pCustomer.DevicesIdentifier = contactDeviceIdentifier
sqlTran.Commit()
return True
Catch sex As SqlException
sqlTran.Rollback()
mHasException = True
Return False
Catch ex As Exception
mHasException = True
mLastException = ex
Return False
End Try
End Using
End Using
End Function
Updates
The update is structured similar adding a new customer while in the update we already have primary keys to find the proper rows and update these records. Since an exception may happen the operation is wrapped in a transaction and committed on success or rolled back on failure.
public Function UpdateCustomer(pCustomer as Customer) As Boolean
Dim successCount as Integer = 0
dim contactUpdateStatement =
<SQL>
UPDATE dbo.Contact
SET FirstName = @FirstName
,@LastName = @LastName
WHERE ContactIdentifier = @Id
</SQL>.Value
Dim contactDevicesUpdateStatement =
<SQL>
UPDATE dbo.ContactContactDevices
SET ContactIdentifier = @ContactIdentifier
,PhoneTypeIdenitfier = @PhoneTypeIdenitfier
,PhoneNumber = @PhoneNumber
,Active = @Active
WHERE Identifier = @Id
</SQL>.Value
Dim customerUpdateStatement =
<SQL>
UPDATE dbo.Customers
SET CompanyName = @CompanyName
,ContactName = @ContactName
,ContactIdentifier = @ContactIdentifier
,ContactTypeIdentifier = @ContactTypeIdentifier
,Street = @Street
,City = @City
,PostalCode = @PostalCode
,CountryIdentfier = @CountryIdentfier
,Phone = @Phone
,ModifiedDate = @ModifiedDate
WHERE CustomerIdentifier = @Id
</SQL>.Value
Using cn As New SqlConnection With{.ConnectionString = ConnectionString}
cn.Open()
Dim sqlTran As SqlTransaction = cn.BeginTransaction()
Using cmd As New SqlCommand With {.Connection = cn, .CommandText = contactUpdateStatement, .Transaction = sqlTran}
Try
cmd.Parameters.AddWithValue("@FirstName", pCustomer.ContactFirstName)
cmd.Parameters.AddWithValue("@LastName", pCustomer.ContactLastName)
cmd.Parameters.AddWithValue("@Id", pCustomer.ContactIdentifier)
successCount = cmd.ExecuteNonQuery()
cmd.Parameters.Clear()
cmd.CommandText = contactDevicesUpdateStatement
cmd.Parameters.AddWithValue("@ContactIdentifier", pCustomer.ContactIdentifier)
cmd.Parameters.AddWithValue("@PhoneTypeIdenitfier", pCustomer.PhoneTypeIdenitfier)
cmd.Parameters.AddWithValue("@PhoneNumber", pCustomer.PhoneNumber)
cmd.Parameters.AddWithValue("@Active", True)
cmd.Parameters.AddWithValue("@Id", pCustomer.DevicesIdentifier)
successCount += cmd.ExecuteNonQuery()
cmd.Parameters.Clear()
cmd.CommandText = customerUpdateStatement
cmd.Parameters.Clear()
cmd.Parameters.AddWithValue("@CompanyName", pCustomer.CompanyName)
cmd.Parameters.AddWithValue("@ContactName", pCustomer.ContactName)
cmd.Parameters.AddWithValue("@ContactIdentifier", pCustomer.ContactIdentifier)
cmd.Parameters.AddWithValue("@ContactTypeIdentifier", pCustomer.ContactTypeIdentifier)
cmd.Parameters.AddWithValue("@Street", pCustomer.Street)
cmd.Parameters.AddWithValue("@City", pCustomer.City)
cmd.Parameters.AddWithValue("@PostalCode", pCustomer.PostalCode)
cmd.Parameters.AddWithValue("@Phone", pCustomer.PhoneNumber)
cmd.Parameters.AddWithValue("@CountryIdentfier", pCustomer.CountryIdentifier)
cmd.Parameters.AddWithValue("@ModifiedDate", Now)
cmd.Parameters.AddWithValue("@Id",pCustomer.CustomerIdentifier)
successCount += cmd.ExecuteNonQuery()
sqlTran.Commit()
Return successCount = 3
Catch sex As SqlException
sqlTran.Rollback()
mHasException = True
Return False
Catch ex As Exception
mHasException = True
mLastException = ex
Return False
End Try
End Using
End Using
End Function
Removing records
To remove a record pressing DELETE key on a record prompts for confirmation. If confirmed three tables are removed with the addition of orders and order details as cascade deletes are set to true. As with inserts and updates since there are multiple tables a transaction wraps around the removal operation.
Public Function RemoveCustomer(pCustomer As Customer ) As Boolean
mHasException = False
Dim removeContactStatement = "DELETE FROM dbo.Contact WHERE ContactIdentifier = @Id"
Dim removeCustomerStatement = "DELETE FROM dbo.Customers WHERE CustomerIdentifier = @Id"
Dim removeDevicesStatement = "DELETE FROM dbo.ContactContactDevices WHERE ContactIdentifier = @Id"
Using cn As New SqlConnection With{.ConnectionString = ConnectionString}
cn.Open()
Dim sqlTran As SqlTransaction = cn.BeginTransaction()
Using cmd As New SqlCommand With {.Connection = cn, .CommandText = removeCustomerStatement, .Transaction = sqlTran}
cmd.Parameters.AddWithValue("@Id", pCustomer.CustomerIdentifier)
Try
cmd.ExecuteNonQuery()
cmd.Parameters.Clear()
cmd.CommandText = removeDevicesStatement
cmd.Parameters.AddWithValue("@Id", pCustomer.ContactIdentifier)
cmd.ExecuteNonQuery()
cmd.Parameters.Clear()
cmd.CommandText = removeContactStatement
cmd.Parameters.AddWithValue("@Id", pCustomer.ContactIdentifier)
cmd.ExecuteNonQuery()
sqlTran.Commit()
Return True
Catch sex As SqlException
sqlTran.Rollback()
mHasException = True
mLastException = sex
Return False
Catch ex As Exception
mHasException = True
mLastException = ex
Return False
End Try
End Using
End Using
End Function
A downside to creating classes as done in this code sample in tangent with usage in SQL statements is you the developer must be diligent to update the queries e.g. if a new column is added then in the read operation make sure to add the column which usually means adding the column to the add, edit methods along any place this data is needed when working with the data in the presentation layer also and run unit test methods against the changed code.
Organization of projects
Both custom components mentioned reside in their own class projects which permits usage in other projects were the only dependencies are on the customer class which resides in a class project for all classes required for this application. One can take both class projects and use in another solution which works with the same database. Both exception handling class and database connection class reside in a separate class project which can be used in another Visual Studio solution.
Classes specific to the application reside in folders within the application project. There are two custom My.Namespace classes which must be in the application as how the .NET Framework is setup these classes cannot be on another project. One is a wrapper for showing message boxes while the other is a stub for sending error emails.
Take a look at ControlsExtensions in the main project which provides an extension method Descendants which is a good method to target all controls of a specific type on a form in in any containers on the form. It’s really useful in this code sample for edit/add validation along with exception handling if an exception is thrown in form load of the main form.
<Runtime.CompilerServices.Extension> _
Public Iterator Function Descendants(Of T As Class)(ByVal control As Control) As IEnumerable(Of T)
For Each child As Control In control.Controls
Dim currentChild = TryCast(child, T)
If currentChild IsNot Nothing Then
Yield currentChild
End If
If child.HasChildren Then
For Each descendant As T In child.Descendants(Of T)()
Yield descendant
Next
End If
Next
End Function
Moving your code to this level
To move to this level a developer that is interested in this must take time to study how the code flows along with having a decent understanding why code has been setup as presented. The wrong approach is to copy and paste parts of code without having a understanding why. Couple code with a sound database schema which reflects your business requirements.
Where to go from here
Once there is a firm understanding of what has been presented consider working with Entity Framework. Entity Framework does require a significant amount of code to get rolling yet once into normal database operations Entity Framework is more elegant than TableAdapter, DataAdapter and even methods presented here. Why not skip right from TableAdapter or DataAdapter to Entity Framework? Because you will miss out on learning how things work which with Entity Framework is less transparent yet once learned is easy to understand.
Entity Framework Read example
In this example the classes which represent the same tables as in the above were created using “Design from database”. To keep things simple, the following loads Customers data.
Keeping simple, the following loads customers and displays several fields. In a real application data would be loaded into a DataGridView using the same custom BindingList as in the non-Entity Framework example above.
Imports System.Data.Entity
Public Class Form1
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
Dim customerList as new List(Of Customer)
Dim customerIdentifiers = Enumerable.Range(1, 10).ToArray()
using cn As New NorthWindEntities()
cn.Configuration.ProxyCreationEnabled = False
cn.Configuration.LazyLoadingEnabled = true
customerList = cn.Customers.
Include(Function(cust As Customer) cust.Contact).
Include("Country").Include("ContactType").
Where(Function(cust) customerIdentifiers.Contains(cust.CustomerIdentifier)).
ToList()
End Using
For Each cust As customer In customerList
Console.WriteLine(
$"{cust.CompanyName,-35} {cust.Contact.FirstName,-12} {cust.Contact.LastName, -12} {cust.ContactType.ContactTitle}")
Next
End Sub
End Class
cn.Configuration.ProxyCreationEnabled = False
cn.Configuration.LazyLoadingEnabled = true
The two lines above tell Entity Framework not to load related tables e.g. Orders in this case is excluded. Since related tables are not loaded we need to use Include which is shown two different ways, one specifying the table name in a string which is fragile since table names change over time this can break while the second method uses lambda to strongly type the expression where condition which is the preferred method. Include is shown for one level but also supports multiple levels also.
This short example provides a quick glance for reading data. Modifying data is very easy too e.g. create a new customer, populate it’s properties, mark the Entry state as modified followed by calling the save method on the context.
Public Sub UpdateCustomer(pCustomer As Customer)
Try
Using cn As New NorthWindEntities
cn.Entry(pCustomer).State = EntityState.Modified
cn.SaveChanges()
End Using
Catch ex As DbUpdateConcurrencyException
' handle Optimistic Concurrency exception
End Try
End Sub
Adding a new customer unlike using a managed data provider to insert and get the new primary key Entity Framework returns the new key upon a successful save.
As mentioned above, Entity Framework is a great option yet not always the best option for desktop application. If you are building desktop applications currently use the methods talk about above, when moving to web applications or service solutions look at Entity Framework current version.
Public Function AddCustomer(pCustomer As Customer) As Integer
Using cn As New NorthWindEntities
cn.Entry(pCustomer).State = EntityState.Added
'
' The save will bring back the new primary key
'
cn.SaveChanges()
'
' After saving the new primary key is contained in Customer
' The caller in this case creates a new record in the DataGridView
' DataSource, a BindingSource.
Return pCustomer.CustomerIdentifier
End Using
End Function
Screen shots
While data is loaded in the data class the function responsible is an Iterator using Yield to pass the current customer name via a delegate to the splash screen which runs in it's own thread.
Splash screen
Once finishing loading of data
Editing screen
Add screen
Remove current customer
Dynamic filter
Source code
Found on MSDN https://code.msdn.microsoft.com/SQL-Server-application-1d1368bc