SQL: Fundamentals for master detail data view with .NET
Introduction
Writing data-centric applications when designed properly rarely use a single database table to store application data.
For example, you would not place orders in a customer table but instead create a table for customers, another table for orders and so on. This article provides details on creating a simple three table solution from a SQL-Server database using SqlClient managed data provider.
In the screenshot above, each numeric value above a ListBox is primary keys only visible in debug mode.
Steps
1. Design schema
Design the schema which encompasses creating each table with auto-incrementing primary keys so that each table can be related. In this sample the master table is a table named product while the detail table is color. Color becomes a master to a size table which makes size table the detail table for colors.
2. Populate tables
Populate the three tables with data.
3. Create SQL Statements
Create SQL select statements with where conditions to ensure data comes back as expected. Developers tend to skip this step and instead will test the relationships between the tables in code. Testing in code can be done but not in the actual project but instead in unit test methods. Once satisfied the relationships are sound using SQL statements or unit test move on to the next step.
Caveat, the best place to write/test SQL statements is in SQL-Server Management Studio. If you don’t have SSMS installed then in your project add a new text file, the base name does not matter but change the file extension from .txt to .sql. Now you can write SQL statements and execute them. If using Visual Studio 2017 a add-in is installed called SQL-Prompt which will provide Intellisense when writing your SQL. Start off by connecting to your SQL database via the connect button while in the .sql file. Once connected add a using statement for the database. SQL-Prompt will kick in and provide assistance with field names available.
4. Create data class
Create a data class, in this class, a method will be needed to read the three tables. The following is a base to work from.
Imports System.Data.SqlClient
Imports System.Windows.Forms
Public Class Operations
''' <summary>
''' Make sure to change "Data Source" to your server
''' </summary>
Private ConnectionString As String = "Data Source=YOUR-PC;" &
"Initial Catalog=MasterRelationsDemo;" &
"Integrated Security=True"
Public Property HasErrors As Boolean
Public Property ExceptionMessage As String
Public Property bsMasterProduct As New BindingSource
Public Property bsDetailsColor As New BindingSource
Public Property bsDetailsSize As New BindingSource
Public Sub LoadData()
Dim ds As New DataSet
Dim productsSelectStatement As String =
<SQL>
SELECT
id,
Name,
Description, '$' + CONVERT(NVARCHAR, [Retail]) AS RetailFormatted,
Retail
FROM dbo.Product
ORDER BY NAME
</SQL>.Value
Dim colorsSelectStatement as String =
<SQL>
SELECT
id,
ProductId,
Color
FROM dbo.ProductColor
</SQL>.Value
Dim sizeSelectStatement as String =
<SQL>
SELECT
id,
Size,
ProductColorId,
DisplayOrder
FROM dbo.ProductSize
</SQL>.Value
Using cn As New SqlConnection With {.ConnectionString = ConnectionString}
Dim da As New SqlDataAdapter(productsSelectStatement, cn)
Try
da.Fill(ds, "Product")
da = New SqlDataAdapter(colorsSelectStatement, cn)
da.Fill(ds, "ProductColor")
'
' Using a language extension method to create a relationship between Product and Color where
' parameter1 and parameter2 are concatenated to form the relationship name.
'
ds.SetRelation("Product", "ProductColor", "Id", "ProductId")
da = New SqlDataAdapter(sizeSelectStatement, cn)
da.Fill(ds, "ProductSize")
'
' Using a language extension method to create a relationship between Color and size where
' parameter1 and parameter2 are concatenated to form the relationship name.
'
ds.SetRelation("ProductColor", "ProductSize", "Id", "ProductColorId")
'
' Configure our BindingSource components to work in the user interface so
' there is no need to (like many developers do) to filter data with events
' for when displayed in three ListBox or ComboBox controls
'
bsMasterProduct.DataSource = ds
bsMasterProduct.DataMember = ds.Tables(0).TableName
bsDetailsColor.DataSource = bsMasterProduct
bsDetailsColor.DataMember = ds.Relations(0).RelationName
bsDetailsSize.DataSource = bsDetailsColor
bsDetailsSize.DataMember = ds.Relations(1).RelationName
'
' Here we show the data in a raw format to the IDE console.
'
If Debugger.IsAttached Then
For Each row As DataRow In ds.Tables("ProductColor").Rows
Console.WriteLine($"{row.Field(Of Integer)("ProductId")}")
For Each cRow As DataRow In row.GetChildRows("ProductColorProductSize")
Console.WriteLine(" " & String.Join(",", cRow.ItemArray))
Next
Next
End If
Catch ex As Exception
HasErrors = True
ExceptionMessage = ex.Message
End Try
End Using
End Sub
''' <summary>
''' This is a simple shell, in a real application the data schema
''' would be more robust to include a full shopping cart that
''' when we add an item to the shopping cart there first is a check
''' to see if the item selected is in stock and if so mark it on hold
''' for x amount of time so if someone else wants to order it and
''' there was one in stock it would appear as out of stock.
'''
''' I go just far enough here for the topic at hand. From having
''' the primary key we can then use SQL to look at the data which
''' as mentioned above would have a robust shopping cart including
''' quanities and more.
''' </summary>
''' <param name="pProductRow"></param>
''' <param name="pColorRow"></param>
''' <param name="pSizeRow"></param>
Public Sub AddToOrder(ByVal pProductRow As DataRow, ByVal pColorRow As DataRow, ByVal pSizeRow As DataRow)
Dim productId As Integer = pProductRow.Field(Of Integer)("id")
Dim colorId As Integer = pColorRow.Field(Of Integer)("id")
Dim sizeId As Integer = pSizeRow.Field(Of Integer)("id")
Using cn As New SqlConnection With {.ConnectionString = ConnectionString}
Using cmd As New SqlCommand With {.Connection = cn}
'
' setup command text to check if in stock and underly logic to complete
' the shopping cart.
'
End Using
End Using
End Sub
End Class
We start off with several class properties along with the database connection string. HasErrors provides a way to determine if there were any exceptions thrown while reading data. If an exception is thrown ExceptionMessage will contain information for the exception.
The following three properties are BindingSource components used in the form to traverse the data. Usually BindingSource components are created and used in a form but that is not a hard rule. By having the BindingSource components in this class keeps all major data components together.
There are two methods, the first LoadData for reading in the data from the three tables. All three tables will be stored into a DataSet via a single Data Adapter. Developers will tend to create three Data Adapters, one for each table, although this is fine why create three when we can reuse a single Data Adapter for retrieving data from all three tables.
SELECT statements are stored in XML literals rather than string variables. Granted there is no value for these queries yet as your statements grow and become complex this is the way to go.
After the first two tables are loaded into the DataSet their relations are setup by primary keys using a language extension method located in the same project.
Public Module DataRelationsExtensions
''' <summary>
''' USed to create a one to many relationship for a master-detail in a DataSet.
''' </summary>
''' <param name="sender"></param>
''' <param name="pMasterTableName">master table</param>
''' <param name="pChildTableName">child table of master table</param>
''' <param name="pMasterKeyColumn">master table primary key</param>
''' <param name="pChildKeyColumn">child table of master's primary key</param>
<DebuggerStepThrough()>
<Runtime.CompilerServices.Extension()>
Public Sub SetRelation(
sender As DataSet,
pMasterTableName As String,
pChildTableName As String,
pMasterKeyColumn As String,
pChildKeyColumn As String)
sender.Relations.Add(New DataRelation(
String.Concat(pMasterTableName, pChildTableName),
sender.Tables(pMasterTableName).Columns(pMasterKeyColumn),
sender.Tables(pChildTableName).Columns(pChildKeyColumn)))
End Sub
End Module
Once the relationships have been set up each table is assigned to the DataSource of the BindingSource components setup as properties in this class.
Note that the product BindingSource DataSource is the DataSet. By setting the first table (Product table) as the DataMember this will permit Products to display in a ListBox. The detail BindingSource DataSource is setup to the product BindingSource with the first relation as the data member. The master DataTable is Color table so this will be what displays in the colors ListBox. Finally the Size BindingSource follows suit as with the color BindingSource
Note: Setting up the BindingSource components is critical, this is as important as a good database schema. When one or the other is incorrect the master-detail will fail. Another failure point is when there is bad data in the detail tables.
5. Load event in form
In the form, in form load event (or place the code in form shown event) we create an instance of the data class created above. In the load event invoke LoadData method followed by assigning each ListBox to a BindingSource component.
There are two events subscribed too for both product and color tables via the BindingSource components for PostionChanged. This is done to show related data when moving from one item in a ListBox to another item in the ListBox.
There is a button on the form solely to show how to get at current records being displayed in the ListBox controls.
Note: In both the data class and form there are lines of code which only work while in debug mode. When not in debug mode these lines of code will not execute. They are here as a guide to see what is going on under the covers.
Imports BackEnd
Public Class Form1
Private ops As New Operations
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
ops.LoadData()
If ops.HasErrors Then
MessageBox.Show($"{ops.ExceptionMessage}")
Exit Sub
End If
ProductsListBox.DataSource = ops.bsMasterProduct
ProductsListBox.DisplayMember = "Name"
ColorsListBox.DataSource = ops.bsDetailsColor
ColorsListBox.DisplayMember = "Color"
SizesListBox.DataSource = ops.bsDetailsSize
SizesListBox.DisplayMember = "Size"
SetupDebuggingDataBindings()
DescriptionLabel.DataBindings.Add("Text", ops.bsMasterProduct, "Description")
RetailLabel.DataBindings.Add("Text", ops.bsMasterProduct, "RetailFormatted")
AddHandler ops.bsMasterProduct.PositionChanged, AddressOf MasterPositionChanged
AddHandler ops.bsDetailsColor.PositionChanged, AddressOf DetailsPositionChanged
ShowCurrentProductAndColor()
End Sub
Private Sub MasterPositionChanged(sender As Object, e As EventArgs)
ShowCurrentProductAndColor()
End Sub
Private Sub DetailsPositionChanged(sender As Object, e As EventArgs)
ShowCurrentProductAndColor()
End Sub
Private Sub ShowCurrentProductAndColor()
If ops.bsMasterProduct.Current IsNot Nothing AndAlso ops.bsDetailsColor.Current IsNot Nothing Then
TextBox1.Text = $"{ops.bsMasterProduct.CurrentRow.Field(Of String)("Name")}," &
$"{ops.bsDetailsColor.CurrentRow.Field(Of String)("Color")}"
End If
End Sub
''' <summary>
''' This method shows how to obtain data for the current selection both directly in the form
''' and also passing information via DataRow objects to the data class Operations.
''' </summary>
''' <param name="sender"></param>
''' <param name="e"></param>
Private Sub SelectedButton_Click(sender As Object, e As EventArgs) Handles SelectedButton.Click
Dim currentProductRow As DataRow = ops.bsMasterProduct.CurrentRow
Dim product As String = currentProductRow.Field(Of String)("Name")
Dim retail As Integer = currentProductRow.Field(Of Integer)("Retail")
Dim currentColorRow As DataRow = CType(ops.bsDetailsColor.Current, DataRowView).Row
Dim color As String = currentColorRow.Field(Of String)("Color")
Dim currentSizeRow As DataRow = CType(ops.bsDetailsSize.Current, DataRowView).Row
Dim size As String = ops.bsDetailsSize.CurrentRow.Field(Of String)("Size")
ops.AddToOrder(currentProductRow, currentColorRow, currentSizeRow)
End Sub
Private Sub SetupDebuggingDataBindings()
If Debugger.IsAttached Then
lblProductIdentifier.Visible = True
lblColorIdentifier.Visible = True
lblSizeIdentifier.Visible = True
lblProductIdentifier.DataBindings.Add("text", ops.bsMasterProduct, "id")
lblColorIdentifier.DataBindings.Add("text", ops.bsDetailsColor, "id")
lblSizeIdentifier.DataBindings.Add("text", ops.bsDetailsSize, "id")
End If
End Sub
End Class
Source code
YOUR-PC
The full project can be found on MSDN here.
Prior to running the code, you need to create the database, tables and populate the tables using a script in the backend data class. Also, change the connection string server from YOUR-PC to your server e.g. .\SQLEXPRESS.
See also
Basics of manually creating a Master-Detail view of a backend database.
This code sample dives deeper into how to work with master-details including using DataTable Expression property on parent and child data.,
Windows forms Master-detail view with add/edit/delete on SQL-Server.
This example demonstrates adding, editing and removal of records.