WPF: Displaying and Editing Many-to-Many Relational Data in a DataGrid
Introduction
This article provides an example of how you could display and let the user edit many-to-many relational data from the Entity Framework in a dynamic and data-bound DataGrid control in WPF by programmatically adding a DataGridCheckBoxColumn to the grid for every entity object that represents a row in the “child” table of the relationship between the two tables:
http://magnusmontin.files.wordpress.com/2013/10/c3a9fmanytomany.png
Many-to-many
A many-to-many relationship is one where two data tables or entities have multiple rows that are connected to one or more rows in the other table.
In this particular example, one user can belong to multiple groups and one group can have multiple users. Another typical example of this kind of relationship is when you have a table representing students and another one representing courses and a student can take several courses simultaneously, while a course can be attended by several students at a time.
In a relational database, a join table is generally used to create the relationships:
/* define the tables */
CREATE TABLE [User]
(
UserId INT NOT NULL IDENTITY (1, 1) PRIMARY KEY,
Username NVARCHAR(10) NOT NULL UNIQUE,
Firstname NVARCHAR(20) NOT NULL,
Lastname NVARCHAR(20) NOT NULL
)
CREATE TABLE [Group]
(
GroupId INT NOT NULL IDENTITY (1, 1) PRIMARY KEY,
GroupName NVARCHAR(20) NOT NULL UNIQUE
)
CREATE TABLE [UserGroups]
(
UserId INT NOT NULL,
GroupId INT NOT NULL,
PRIMARY KEY (UserId, GroupId),
FOREIGN KEY (UserId) REFERENCES [User] (UserId),
FOREIGN KEY (GroupId) REFERENCES [Group] (GroupId)
)
/* insert some sample data */
INSERT INTO [User] (Username, Firstname, Lastname) VALUES ('magmo', 'Magnus', 'Montin')
INSERT INTO [User] (Username, Firstname, Lastname) VALUES ('johndo', 'John', 'Doe')
INSERT INTO [User] (Username, Firstname, Lastname) VALUES ('janedo', 'Jane', 'Doe')
INSERT INTO [Group] (GroupName) VALUES ('Administrators')
INSERT INTO [Group] (GroupName) VALUES ('Publishers')
INSERT INTO [Group] (GroupName) VALUES ('Readers')
INSERT INTO UserGroups (UserId, GroupId)
SELECT u.UserId, g.GroupId
FROM [User] u
CROSS JOIN [Group] g
WHERE u.Username ='magmo'
INSERT INTO UserGroups (UserId, GroupId)
SELECT u.UserId, g.GroupId
FROM [User] u
CROSS JOIN [Group] g
WHERE u.Username ='johndo'
AND (g.GroupName = 'Publishers' OR g.GroupName = 'Readers')
INSERT INTO UserGroups (UserId, GroupId)
SELECT u.UserId, g.GroupId
FROM [User] u
CROSS JOIN [Group] g
WHERE u.Username ='janedo'
AND g.GroupName = 'Readers'
CREATE TABLE (Transact-SQL) (MSDN)
INSERT (Transact-SQL) (MSDN)
Provided that the join table contains only the keys to the two related tables and no additional columns, the Entity Data Model Wizard in Visual Studio 2012 will by default create the below two entity classes when you add a new ADO.NET Entity Data Model and import all three tables above from an already existing database:
public partial class User
{
public User()
{
this.Groups = new HashSet<Group>();
}
public int UserId { get; set; }
public string Username { get; set; }
public string Firstname { get; set; }
public string Lastname { get; set; }
public virtual ICollection<Group> Groups { get; set; }
}
public partial class Group
{
public Group()
{
this.Users = new HashSet<User>();
}
public int GroupId { get; set; }
public string GroupName { get; set; }
public virtual ICollection<User> Users { get; set; }
}
Creating an Entity Data Model from a Database (MSDN)
Note that both generated entity classes have navigation properties of type System.Collections.Generic.ICollection<T> to get to their related data. As the classes have the partial modifier you can extend them with any additional properties that are not mapped against the database. For example, you might want to be able to get the full name of a user:
public partial class User
{
public string FullName
{
get
{
return string.Format("{0} {1}",
this.Firstname, this.Lastname);
}
}
}
Displaying data
Provided that you want to display a row per user and a column per group like in the image above, the DataGrid in the view should bind to a collection of User objects of the view model. Besides this collection, the view model also needs to expose a collection of all Group objects:
public class ViewModel : IDisposable
{
private readonly Entities _context = new Entities();
public ViewModel()
{
this.Groups = _context.Groups.ToList();
this.Users = _context.Users.ToList();
}
#region Properties
public List<Group> Groups
{
get;
private set;
}
public List<User> Users
{
get;
private set;
}
#endregion
public void Dispose()
{
if (_context != null)
_context.Dispose();
}
}
The view can then iterate through this collection of Group objects and add a System.Windows.Controls.DataGridCheckBoxColumn for each one. The value of the property specified by the Path property of the Binding property of the DataGridCheckBoxColumn determines whether the generated CheckBox control will be checked or unchecked.
You typically use the Binding property of a DataGridCheckBoxColumn to bind to a source property of type System.Boolean (bool). However, you can also bind to a source property of some other type and use a value converter to convert the value of this type to a Boolean value that can be set on the CheckBox’s IsChecked property and this is exactly what you want to do here.
IValueConverter Interface (MSDN)
Remember that the DataGrid control will be bound to the collection of User objects of the view model. This means that the source object of the binding will be the User object and the User class doesn’t have any Boolean properties. What you want to do here is to bind to the Groups collection property of the User object and then have the converter return true or false depending on whether the User object’s Group collection contains the group that represents the current column. This means that you also need to pass the Group object to the converter and you can use the ConverterParameter property of the System.Windows.Data.Binding object that is set as the value for the Binding property of the DataGridCheckBoxColumn for this:
internal class GroupsToBooleanConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
ICollection<Group> groups = value as ICollection<Group>;
if (groups != null)
{
Group group = parameter as Group;
if (group != null)
return groups.Contains(group);
}
return false;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
public partial class MainWindow : Window
{
private readonly ViewModel _viewModel = new ViewModel();
public MainWindow()
{
InitializeComponent();
this.DataContext = _viewModel;
CreateDataGrid();
}
private void CreateDataGrid()
{
this.dataGrid.AutoGenerateColumns = false;
this.dataGrid.CanUserAddRows = false;
/* Add a column for the displaying the full name of the user */
this.dataGrid.Columns.Add(new DataGridTextColumn()
{
Header = "User",
Binding = new Binding("FullName")
{
Mode = BindingMode.OneWay
}
});
/* Add a column for each group */
foreach (Group group in _viewModel.Groups)
{
DataGridCheckBoxColumn chkBoxColumn = new DataGridCheckBoxColumn();
chkBoxColumn.Header = group.GroupName;
Binding binding = new Binding("Groups");
GroupsToBooleanConverter converter = new GroupsToBooleanConverter();
binding.Converter = converter;
binding.ConverterParameter = group;
binding.Mode = BindingMode.OneWay;
chkBoxColumn.Binding = binding;
this.dataGrid.Columns.Add(chkBoxColumn);
}
/* Bind the ItemsSource property of the DataGrid to the Users collection */
this.dataGrid.SetBinding(DataGrid.ItemsSourceProperty, "Users");
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
base.OnClosing(e);
if (_viewModel != null)
_viewModel.Dispose();
}
}
<Window x:Class="Mm.DynamicDataGrid.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Mm.DynamicDataGrid.Wpf"
Title="MainWindow" Height="350" Width="525">
<StackPanel Margin="10">
<DataGrid x:Name="dataGrid"/>
</StackPanel>
</Window>
Note that the XAML markup for the view contains only an empty DataGrid and the columns and bindings are defined in code. This is perfectly acceptable and does not break the MVVM pattern since all code is view related. The MVVM design pattern is about separating application logic from your view logic and not necessarily about eliminating code out of the views. You may be used to define the columns and bindings of a DataGrid in pure XAML but if you look at the code for the MainWindow class above, you should notice that it just creates the columns and binds to source properties of the view model just like you would do in XAML. The reason why this is done in C# code instead of static XAML markup is of course that the number of groups (columns) is dynamic and may vary over time. For example, if you add another row to the Group database table, you want another column representing this new group to show up in the DataGrid without any changes to the code.
Implementing the MVVM Pattern (MSDN)
Editing data
As the Mode property of the Binding objects that are being set as the values for the Binding properties of the DataGridCheckBoxColumn objects created in the foreach loop in the above code is set to System.Windows.Data.BindingMode.OneWay, the user won’t be able to check or uncheck the CheckBox controls in the DataGrid. The DataGrid will be read-only. If you require the ability to change the relationships between the entities, you somehow need to add or remove groups from the Groups collection property of the corresponding User object when the user checks or unchecks a CheckBox.
There are different ways of solving this. You could use a TwoWay binding (by setting the Mode property of the Binding object for each column to BindingMode.TwoWay) and implement the ConvertBack method of the GroupsToBooleanConverter class to modify the Groups collection of the bound User object. However, this method will only receive the Boolean value (value argument) of the IsChecked property of the CheckBox and the Group object (parameter argument). It doesn’t have any access to the source property, i.e. the User object. You could make the GroupsToBooleanConverter class derive from the System.Windows.DependencyObject class, add a dependency property to it and bind the source value to this dependency property. Note however that as a converter is not part of the visual tree, it doesn’t have any DataContext and this means that you have to get or inherit this from somewhere. You can read this external article published on the CodeProject for some ways of doing this if you decide to take this approach.
Dependency Properties Overview (MSDN)
Another option is to bind the Command property of each CheckBox control to an ICommand property of the view model and let this command take care of adding and removing Group objects from the User object’s Groups collection property.
Commanding Overview (MSDN)
Handling events in an MVVM WPF application (my blog)
A DelegateCommand is common implementation of the System.Windows.Input.ICommand interface in WPF applications that uses the MVVM pattern. It invokes delegates when executing and querying executable status. There is no DelegateCommand class available in WPF out-of-the-box but there is one included in Prism, the framework and guidance for building WPF and Silverlight applications from the Microsoft Patterns and Practices Team. If you are not using Prism, you can implement your own one:
public class DelegateCommand : ICommand
{
private readonly Predicate<object> _canExecute;
private readonly Action<object> _execute;
public DelegateCommand(Action<object> execute)
: this(execute, null)
{
}
public DelegateCommand(Action<object> execute, Predicate<object> canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
if (_canExecute == null)
return true;
return _canExecute(parameter);
}
public void Execute(object parameter)
{
_execute(parameter);
}
public event EventHandler CanExecuteChanged
{
add
{
CommandManager.RequerySuggested += value;
}
remove
{
CommandManager.RequerySuggested -= value;
}
}
}
public class ViewModel : IDisposable
{
...
private readonly DelegateCommand _addOrRemoveGroupCommand;
public ViewModel()
{
_addOrRemoveGroupCommand = new DelegateCommand(AddOrRemoveGroup);
...
}
#region Properties
...
#endregion
#region Commands
public DelegateCommand AddOrRemoveGroupCommand
{
get { return _addOrRemoveGroupCommand; }
}
private void AddOrRemoveGroup(object parameter)
{
//TODO: Implement logic
}
#endregion
...
}
To be able to access the CheckBox control that is generated by a DataGridCheckBoxColumn, you can create your own class that extends the DataGridCheckBoxColumn class and overrides the GenerateElement method. This method is responsible for creating the actual CheckBox control that is bound to the column’s Binding property.
In this method you could then bind the Command property of the created CheckBox control to the DelegateCommand of the view model. In the code below, a System.Windows.Data.RelativeSource object is used to describe the location of the binding source (the window) relative to the position of the binding target (the CheckBox).
internal class GroupDataGridCheckBoxColumn : DataGridCheckBoxColumn
{
protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
{
CheckBox checkBox = base.GenerateElement(cell, dataItem) as CheckBox;
checkBox.IsHitTestVisible = true;
/* Set Command binding */
Binding commandBinding = new Binding("DataContext.AddOrRemoveGroupCommand");
commandBinding.RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Window), 1);
checkBox.SetBinding(CheckBox.CommandProperty, commandBinding);
//TODO: Set CommandParameter
return checkBox;
}
}
Also note that you must set the IsHitTestVisible property of the returned CheckBox control to true in order to enable it as the base class’ (DataGridCheckBoxColumn) implementation of the GenerateElement method will return a read-only CheckBox control. By default, a user must double-click a cell in a DataGrid to enter the edit mode and when this happens for a DataGridCheckBoxColumn its GenerateEditingElement method is called to create an enabled CheckBox control. However, in this case the column will actually always be in read-only mode as the Mode property of Binding object set as the value for the Binding property is set to BindingMode.OneWay and the GenerateEditingElement method will never be called. By making the “read-only” CheckBox enabled by simply changing the value of its IsHitTestVisible property, the user will be able to check and uncheck it without having to double-click the cell first.
For the command of the view model to know what to do when it gets executed, you have to pass it some parameters. It needs to know which Group object that is to be added or removed to or from which User object. When you want to pass a parameter to a command from a view you do so by using the CommandParameter property. Although the CheckBox control – like any other command aware controls – only has a single CommandParameter property, you can pass it multiple values by using a System .Windows.Data.MultiBinding with a converter class that implements the System.Windows.Data.IMultiValueConverter interface.
MultiBinding Class (MSDN)
IMultiValueConverter Interface (MSDN)
In the code below, the CommandParameter property of the CheckBox control created by the GenerateElement method of the custom GroupDataGridCheckBoxColumn class uses a MultiBinding object to bind to the value of the IsChecked property of the generated CheckBox control itself, the User object and the Group object. Note that the Group object is passed to the constructor of the GroupDataGridCheckBoxColumn class when the column is created in the view while the User object is the DataContext of the CheckBox control.
internal class GroupDataGridCheckBoxColumn : DataGridCheckBoxColumn
{
private readonly Group _group;
public GroupDataGridCheckBoxColumn(Group group)
: base()
{
this._group = group;
}
protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
{
CheckBox checkBox = base.GenerateElement(cell, dataItem) as CheckBox;
checkBox.IsHitTestVisible = true;
/* Set Command binding */
Binding commandBinding = new Binding("DataContext.AddOrRemoveGroupCommand");
commandBinding.RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Window), 1);
checkBox.SetBinding(CheckBox.CommandProperty, commandBinding);
/* Set Command parameter */
MultiBinding commandParameterBinding = new MultiBinding();
commandParameterBinding.Converter = new CommandParameterMultiConverter();
commandParameterBinding.Bindings.Add(new Binding("IsChecked") { RelativeSource = RelativeSource.Self });
commandParameterBinding.Bindings.Add(new Binding(".")); //the user object
commandParameterBinding.Bindings.Add(new Binding(".") { Source = this._group }); //the group object
checkBox.SetBinding(CheckBox.CommandParameterProperty, commandParameterBinding);
return checkBox;
}
}
public partial class MainWindow : Window
{
...
private void CreateDataGrid()
{
...
/* Add a column for each group */
foreach (Group group in _viewModel.Groups)
{
GroupDataGridCheckBoxColumn chkBoxColumn =
new GroupDataGridCheckBoxColumn(group);
...
this.dataGrid.Columns.Add(chkBoxColumn);
}
...
}
...
}
The multi value converter simply returns a copy of the bound values as an array of objects that the view model can use to perform the logic of modifying the relationships between the objects by adding or removing the Group object from User object based on the value of the IsChecked property of the CheckBox, i.e. if it was checked or unchecked:
public class CommandParameterMultiConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
return values.Clone();
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
public class ViewModel : IDisposable
{
...
private void AddOrRemoveGroup(object parameter)
{
object[] parameters = parameter as object[];
if (parameters == null)
throw new ArgumentNullException("parameters");
if (!parameters.Length.Equals(3))
throw new ArgumentException("Invalid number of arguments.", "parameters");
if (!(parameters[0] is bool))
throw new ArgumentException("First argument is invalid.", "parameters");
User user = parameters[1] as User;
if (user == null)
throw new ArgumentException("Second argument is invalid.", "parameters");
Group group = parameters[2] as Group;
if (group == null)
throw new ArgumentException("Third argument is invalid.", "parameters");
bool isAdd = Convert.ToBoolean(parameters[0]);
bool existsInCollecton = user.Groups.Contains(group);
if (isAdd && !existsInCollecton)
user.Groups.Add(group);
else if (!isAdd && existsInCollecton)
user.Groups.Remove(group);
}
...
}
To be able to persist the changes back to the data storage, you could then add a Button control to the view and bind it to another command of the view model that invokes a delegate that simply calls the SaveChanges method of the Entity Framework context:
<Window x:Class="Mm.DynamicDataGrid.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Mm.DynamicDataGrid.Wpf"
Title="MainWindow" Height="350" Width="525">
<StackPanel Margin="10">
<DataGrid x:Name="dataGrid"/>
<Button Content="Save" Margin="0 10 0 0"
Command="{Binding SaveCommand}" />
</StackPanel>
</Window>
public class ViewModel : IDisposable
{
private readonly Entities _context = new Entities();
private readonly DelegateCommand _saveCommand;
...
public ViewModel()
{
_saveCommand = new DelegateCommand(SaveChanges);
...
}
...
#region Commands
...
public DelegateCommand SaveCommand
{
get { return _saveCommand; }
}
private void SaveChanges(object parameter)
{
_context.SaveChanges();
}
#endregion
...
}
If you want to be able to reuse the custom DataGridCheckBoxColumn for more than one type of entity, you could create a generic class with a type parameter that specifies the entity type for the columns and pass the command as a constructor argument:
public class CustomDataGridCheckBoxColumn<T> : DataGridCheckBoxColumn
{
private readonly T _columnEntityObject;
private readonly ICommand _addOrRemoveCommand;
public CustomDataGridCheckBoxColumn(T columnEntityObject, ICommand addOrRemoveCommand)
: base()
{
this._columnEntityObject = columnEntityObject;
this._addOrRemoveCommand = addOrRemoveCommand;
}
protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem)
{
CheckBox checkBox = base.GenerateElement(cell, dataItem) as CheckBox;
checkBox.IsHitTestVisible = true;
checkBox.SetValue(CheckBox.CommandProperty, _addOrRemoveCommand);
/* Set the CommandParameter binding */
MultiBinding commandParameterBinding = new MultiBinding();
commandParameterBinding.Converter = new CommandParameterMultiConverter();
commandParameterBinding.Bindings.Add(new Binding("IsChecked") { RelativeSource = RelativeSource.Self
//the data bound object:
commandParameterBinding.Bindings.Add(new Binding("."));
//the column entity object:
commandParameterBinding.Bindings.Add(new Binding(".") { Source = this._columnEntityObject });
checkBox.SetBinding(CheckBox.CommandParameterProperty, commandParameterBinding);
return checkBox;
}
}
private void CreateDataGrid()
{
...
foreach (Group group in _viewModel.Groups)
{
CustomDataGridCheckBoxColumn<Group> chkBoxColumn =
new CustomDataGridCheckBoxColumn<Group>(group, _viewModel.AddOrRemoveGroupCommand);
...
this.dataGrid.Columns.Add(chkBoxColumn);
}
...
}