Silverlight ObjectDataSource
A DataSource control represents a data object that acts as a data-interface for the data bound controls. In terms of MVVM pattern, it represents the ViewModel. Silverlight 3 introduces the DomainDataSource as part of .net RIA Data Services, that can be used as a binding source for DataGrid, DataForm and other data-bound controls. However, if you are still working with Silverlight 2 or researching alternatives , this post introduces ObjectDataSource.
ObjectDataSource
Typically when developing business applications that entail editing one or more entity, you have to add support for state management, implement IEditableObject, and wire up the validation logic, among other essential stuff. And you have to repeat the same process for every entity that needs editing. ObjectDataSource provides one way to encapsulate above functionality and make it reusable.
If you have followed my previous posts on building business applications with Silverlight, you know that it takes a fair amount code for add, update, delete and validation functionality. The ObjectDataSource works with custom business objects and supports automatic data retrieval, add, update, delete, custom paging, custom sorting and custom validation of data, declaratively without extensive code.
It wraps the underlying entity in a DataItem class, and provides automatic state management, as well as implementing IEditableObject support. ObjectDataSource provides a DataList property to facilitate DataBinding to DataGrid and a SelectedItem property for formview scenarios. You can either manually load data or implement IObjectDataProvider to automate data loading and paging.
Baseline Application
Before going into details of ObjectDataSource, lets first build a simple baseline starter application. Create a new SilverlightApplication and the corresponding Web Application to host and test the SilverlightApplication. We will use a WCF service to provide data to client.
First add a reference for System.Runtime.Serialization. Next create a data contract, as a Person class that will be sent back to the client as shown:
[DataContract]
public class Person {
[DataMember]
public string PersonId { get; set; }
[DataMember]
public string FirstName { get; set; }
[DataMember]
public string LastName { get; set; }
[DataMember]
public int Age { get; set; }
}
Next add a Silverlight-enabled WCF service and call it PeopleSevice. Add method GetData to the code behind PeopleService.svc.cs as shown.
[OperationContract]
public List<Person> GetData() {
List<Person> personList = new List<Person>();
for (int i = 0; i < 10; i++) {
personList.Add(new Person() {
PersonId = Guid.NewGuid().ToString(),
FirstName = string.Format("First Name {0}", i),
LastName = string.Format("Last Name {0}", i),
Age = i
});
}
return personList;
}
Here we are just creating test data on demand. In an actual production application, you will be calling database and paging, perhaps using Linq Skip and Take functionality or Top and RowNumber features in SQL (see the sample application included in download for paging, sorting and filter functionality).
Now add a service reference to the the SilverlightApplication client and call it PeopleServiceReference. (use Discover button to see PeopleService). Next add System.Windows.Control.Data reference to the SilverlightApplication project.
Add the following XAML to Page.xaml.
<UserControl x:Class="SilverlightApplication.Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data">
<Grid x:Name="LayoutRoot" Background="White">
<data:DataGrid x:Name="peopleDataGrid" AutoGenerateColumns="False"
Margin="10" RowHeight="22" >
<data:DataGrid.Columns>
<data:DataGridTextColumn Header="First Name" Binding="{Binding FirstName,Mode=TwoWay}" />
<data:DataGridTextColumn Header="Last Name" Binding="{Binding LastName,Mode=TwoWay}" />
<data:DataGridTextColumn Header="Age" Binding="{Binding Age,Mode=TwoWay}" />
</data:DataGrid.Columns>
</data:DataGrid>
</Grid>
</UserControl>
And following code to page.xaml.cs code behind (note: error handling omitted for readability)
public partial class Page : UserControl {
public Page() {
InitializeComponent();
this.Loaded += new RoutedEventHandler(Page_Loaded);
}
void Page_Loaded(object sender, RoutedEventArgs e) {
GetData();
}
private void GetData() {
PeopleServiceClient proxy = new PeopleServiceClient();
proxy.GetDataCompleted += new EventHandler<GetDataCompletedEventArgs>(proxy_GetDataCompleted);
proxy.GetDataAsync();
}
void proxy_GetDataCompleted(object sender, GetDataCompletedEventArgs e) {
this.peopleDataGrid.ItemsSource = e.Result;
}
}
Compile and test the application.
We now have a baseline application that gets data from server and show that to user. Next we will use ObjectDataSource to enable change, add, delete, undelete and save functionality along with custom validation.
Application using ObjectDataSource
Add Neon.Windows.Data reference to client SilverlightApplication. This will bring Neon.Window.* namespace into the project and make available ObjectDataSource and related artifacts.
In order for ObjectDataSource to manage underlying data (entity), you have to implement IDataItemData interface. This allows DataItem to make copy of data to save original data and find data using Key field value. Add a partial class Person.cs as shown to the client SilverlightApplication (Note: Use the same namespace declaration as the Person class generated by WCF Add Service Reference)
namespace SilverlightApplication.PeopleServiceReference {
public partial class Person : IDataItemData<Person> {
public Person Copy() {
return (Person)this.MemberwiseClone();
}
public bool ReInit(Person data) {
return false;
}
public object KeyValue {
get { return this.PersonId; }
}
public bool Equals(Person other) {
return this.PersonId.Equals(other.PersonId, StringComparison.InvariantCultureIgnoreCase);
}
}
}
ReInit method provides you opportunity to copy data saved in database to the client cached data. This would be useful for fields like version and auto generated field values. Now just modify proxy_GetDataCompleted in Page.xaml.cs as shown
void proxy_GetDataCompleted(object sender, GetDataCompletedEventArgs e) {
ObjectDataSource<Person> ds = new ObjectDataSource<Person>();
ds.LoadData(e.Result);
peopleDataGrid.ItemsSource = ds.DataList;
}
Also modify DataGrid.Columns entry in Page.xaml as follows
<data:DataGrid.Columns>
<data:DataGridTextColumn Header="First Name" Binding="{Binding Data.FirstName,Mode=TwoWay}" />
<data:DataGridTextColumn Header="Last Name" Binding="{Binding Data.LastName,Mode=TwoWay}" />
<data:DataGridTextColumn Header="Age" Binding="{Binding Data.Age,Mode=TwoWay}" />
</data:DataGrid.Columns>
Note that binding for for DataGridTextColumns have been changed from PropertyName to Data.PropertyName. This is because ObjectDataSource wraps each Person entity into a DataItem object and exposes it as a Data property. This allows DataItem to carry out automatic state management and provide IEditableObject interface implementation.
IObjectDataProvider
You can always manually load data on demand, but it is easier to offload that functionality to ObjectDataSource. ObjectDataSource knows how to retrieve data using any provider that implements IObjectDataProvider. Provider based pattern allows ObjectDataSource to work with any data store, all you have to do is to implement IObjectDataProvider and interact with your custom data store.
Add a new class called PeopleProvider as shown:
namespace SilverlightApplication.Providers {
public class PeeopleProvider : IObjectDataProvider<Person> {
public void GetDataAsync(string objectInfo, System.Collections.Generic.Dictionary<string, string> sortExpression
, int startRow, int endRow, ObservableCollection<Neon.Web.Data.WhereParameterData> whereParameters) {
PeopleServiceClient proxy = new PeopleServiceClient();
proxy.GetDataCompleted += new EventHandler<GetDataCompletedEventArgs>(proxy_GetDataCompleted);
proxy.GetDataAsync();
}
public event EventHandler<ObjectDataEventArgs<Person>> ObjectDataAvaliable;
//
void proxy_GetDataCompleted(object sender, GetDataCompletedEventArgs e) {
OnObjectDataAvaliable(e.Result);
}
protected virtual void OnObjectDataAvaliable(ObservableCollection<Person> dataCollection) {
if (null != ObjectDataAvaliable) {
Application.Current.RootVisual.Dispatcher.BeginInvoke(() =>
ObjectDataAvaliable(this, new ObjectDataEventArgs<Person>() { DataCollection = dataCollection })
);
}
}
//
public void SaveDataAsync(string objectInfo, ObservableCollection<Neon.Web.Data.ISavableData<Person>> dataList) {
throw new NotImplementedException();
}
public event EventHandler<SaveDataCompletedEventArgs<Person>> SaveDataCompleted;
}
}
For now we will just concentrate on data retrieval part, we will see how to Save data later.
Comment out proxy_GetDataCompleted in Page.xaml.cs and change GetData as shown
private void GetData() {
ObjectDataSource<Person> ds = new ObjectDataSource<Person>();
peopleDataGrid.ItemsSource = ds.DataList;
ds.AutoFetchData = true;
ds.ObjectDataProvider = new PeopleProvider();
}
Here we are providing ObjectDataSource with PeopleProvider to fetch data on demand. Also with AutoFetchData set to true, ObjectDataSource will automatically fetch data on load.
TypedDataSource
Setting up a data source in code works, but in the sprit of declarative code design, it will be great if we can define the data source in XAML. We can easily do that by setting up a typed data source. Create a new class call PeopleDataSource as shown (optionally you can override methods as needed)
namespace SilverlightApplication.Controls {
public class PeopleDataSource : ObjectDataSource<Person> {
}
}
Now comment out GetData code in page.xaml.cs and add following xaml markup to page.xaml
<UserControl x:Class="SilverlightApplication.Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
xmlns:ctrl="clr-namespace:SilverlightApplication.Controls"
xmlns:prov="clr-namespace:SilverlightApplication.Providers"
>
<UserControl.DataContext>
<ctrl:PeopleDataSource AutoFetchData="True">
<ctrl:PeopleDataSource.ObjectDataProvider>
<prov:PeopleProvider/>
</ctrl:PeopleDataSource.ObjectDataProvider>
</ctrl:PeopleDataSource>
</UserControl.DataContext>
<Grid x:Name="LayoutRoot" Background="White">
<data:DataGrid x:Name="peopleDataGrid" AutoGenerateColumns="False"
Margin="10" RowHeight="22" ItemsSource="{Binding DataList}" >
Note that DataGrid ItemsSource is data bound to DataList property of PeopleDataSource. Compile and test the application.
ItemState
ObjectDataSource automatically keeps track of state of each item. An item can be in any one of the following states: NoChange, Changed, New, Deleted or Saved. We can display state of the item using built in RowStateViewModel property on the DataItem.
Add a new DataGridTemplateColumn as the first column in DataGrid.
<data:DataGridTemplateColumn Width="6" MinWidth="6" MaxWidth="6"
CanUserResize="False" CanUserReorder="False" CanUserSort="False" IsReadOnly="True">
<data:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Width="6" DataContext="{Binding RowStateViewModel}" Background="{Binding StateBackground}"
ToolTipService.ToolTip="{Binding StateToolTip}"/>
</DataTemplate>
</data:DataGridTemplateColumn.CellTemplate>
</data:DataGridTemplateColumn>
Make some changes to data, note that row state changes to Changed and that new state is also reflected on UI.
After edit to FirstName =>
Add
You can display a custom UI and add data to the DataList. Alternatively, you can also take advantage of inline add functionality for DataGrid by setting AutoAddRow property on the ObjectDataSource to true.
Modify PeopleDataSource declaration in Page.xaml and set AutoAddRow property to true.
<ctrl:PeopleDataSource AutoFetchData="True" AutoAddRow="True">
Now DataGrid allows addition of new Person.
Add New Person =>
Delete and Undelete
Add following code to page.xaml.cs to enable users to delete data
public Page() {
InitializeComponent();
this.Loaded += new RoutedEventHandler(Page_Loaded);
peopleDataGrid.KeyDown += new KeyEventHandler(peopleDataGrid_KeyDown);
peopleDataGrid.BeginningEdit += new EventHandler<DataGridBeginningEditEventArgs>(peopleDataGrid_BeginningEdit);
}
void peopleDataGrid_KeyDown(object sender, KeyEventArgs e) {
if (e.Key == Key.Delete) {
((PeopleDataSource)this.DataContext).Delete(new DataItem<Person>[] { (DataItem<Person>)peopleDataGrid.SelectedItem });
}
}
void peopleDataGrid_BeginningEdit(object sender, DataGridBeginningEditEventArgs e) {
DataItem<Person> dataItem = e.Row.DataContext as DataItem<Person>;
if (DataItemState.Deleted == dataItem.State) {
MessageBoxResult result = MessageBox.Show("Can not change Deleted item, do you want to Undelete and make the item editable?"
, "Error", MessageBoxButton.OKCancel);
if (result == MessageBoxResult.OK) {
dataItem.State = dataItem.PreviousState;
e.Cancel = false;
} else {
e.Cancel = true;
}
}
}
In the KeyDown handler, we are listening for the delete key and call the Delete method of ObjectDataSource. When user deletes a row, its state is changed to Deleted and row state color it is changed to DarkGray.
In the BeginginEdit, we are checking for the delete state to prevent user for deleting data. If selected item has been deleted, we alert the user and provide an option to undelete. This is possible because ObjectDataSource never removes deleted item from collection, it is just marked as deleted, till data is actually saved to backend data store.
=>
=>
Validation
You can hook up your own validation code by implementing in IDataItemFactory. If provided, ObjectDataSource will use IDataItemFactory to build new DataItem. This allows you to supply your own DataItem derivative, and/or customize setup by providing a validator.
Lets first create a PersonValidator class as shown (implementing last name required check)
namespace SilverlightApplication.Data {
public class PersonValidator : DataItemValidator<Person> {
public PersonValidator(DataItem<Person> dataItem)
: base(dataItem) {
}
public override void Validate(string propertyName) {
if ("LastName" == propertyName) {
if (string.IsNullOrEmpty(this.DataItem.Data.LastName)) {
RegisterError(propertyName, "LastName is required");
} else {
ClearError(propertyName);
}
}
}
public override void Validate() {
Validate("LastName");
}
}
}
Now create the PersonDataItemFactory as shown
namespace SilverlightApplication.Providers {
public class PersonDataItemFactory : DataItemFactory<Person> {
public override DataItem<Person> GetEmtpyDataItem() {
DataItem<Person> personDataItem = base.GetEmtpyDataItem(
new Person() { PersonId = Guid.NewGuid().ToString() });
personDataItem.Validator = new PersonValidator(personDataItem);
personDataItem.State = DataItemState.New;
return personDataItem;
}
public override DataItem<Person> GetDataItem(Person data) {
DataItem<Person> personDataItem = base.GetDataItem(data);
personDataItem.Validator = new PersonValidator(personDataItem);
return personDataItem;
}
}
}
Modify Page.xaml to set PeopleDataSource.DataItemFactory to our custom PersonDataItemFactory as shown:
<UserControl.DataContext>
<ctrl:PeopleDataSource AutoFetchData="True" AutoAddRow="True">
<ctrl:PeopleDataSource.ObjectDataProvider>
<prov:PeopleProvider/>
</ctrl:PeopleDataSource.ObjectDataProvider>
<ctrl:PeopleDataSource.DataItemFactory>
<prov:PersonDataItemFactory/>
</ctrl:PeopleDataSource.DataItemFactory>
</ctrl:PeopleDataSource>
</UserControl.DataContext>
Now when you add new item or change existing item and do not provide last name, user is displayed an error. Note above is just one way of setting up validation. You can setup your own custom validation framework (perheps using meta data to automate routine validation.
)
Save
ObjectDataSource and DataItem keeps track of all the changes. It also provides access to both original and changed data. In order to save data, you can either manually inspect DataItem state and write code or better yet, just write your save routine in SaveDataAsync method of IObjectDataProvider. This allows you to take advantage of Batched saved functionality and also frees you from state management when data is saved.
First we need to modify PeopleService to provide SaveData functionality. Add reference to Neon.Web.Data to SilverlightApplication.Web project. Next add class PersonSavableData.cs as shown. (This is completely optional, you are free to move data as per your own custom DTO schemes, this just one way of moving data.)
[System.Runtime.Serialization.DataContractAttribute(
Name = "PersonSavableData",
Namespace = "http://schemas.datacontract.org/2004/07/Neon.Web.Data")]
public class PersonSavableData : ISavableData<Person> {
[DataMember]
public Person Data { get; set; }
[DataMember]
public Person OriginalData { get; set; }
[DataMember]
public int ActionType { get; set; }
[DataMember]
public bool ActionTaken { get; set; }
[DataMember]
public DateTime ActionDate { get; set; }
[DataMember]
public Dictionary<string, string> Errors { get; set; }
}
PersonSavableData implements ISavableData and provides access to original and modified data as well as action to take on that particular instance. Next add SaveData method to PeopleService as shown:
[OperationContract]
public List<PersonSavableData> SaveData(string objectInfo, List<PersonSavableData> dataList) {
List<PersonSavableData> savedData = dataList;
List<Person> dataItems = dataList.Select<PersonSavableData, Person>(d => d.Data).ToList();
Func<Person, Person, int> saveOperation;
//
foreach (PersonSavableData savableData in dataList) {
savableData.ActionDate = DateTime.Now;
savableData.ActionTaken = false;
// validate data
// if validation fails continue
switch (savableData.ActionType) {
default:
case (int)ActionType.Update:
saveOperation = UpdateData;
break;
case (int)ActionType.Insert:
saveOperation = InsertData;
break;
case (int)ActionType.Delete:
saveOperation = DeleteData;
break;
}
try {
if (0 < saveOperation(savableData.Data, savableData.OriginalData)) {
savableData.ActionTaken = true;
}
} catch (Exception ex) {
if (null == savableData.Errors) {
savableData.Errors = new Dictionary<string, string>();
}
savableData.Errors.Add("Exception", ex.Message);
}
}
return savedData;
}
int UpdateData(Person person, Person originalPerson) {
return 1;
}
int InsertData(Person person, Person originalPerson) {
return 1;
}
int DeleteData(Person person, Person originalPerson) {
return 1;
}
In the SaveData method, we iterate over data to save and based on action specified, call appropriate Update, Delete or Insert function. (Here are we just using stub methods for demo.) Now update the service reference in SilverlightApplication, this will bring over PersonSavableData and update PeopleServiceClient with SaveData method. Add partial class PersonSavableData to SilverlightApplication as shown
namespace SilverlightApplication.PeopleServiceReference {
public partial class PersonSavableData : ISavableData<Person> {
}
}
This allows us to cast from generic ISavableData<Person> to PersonSavableData. Also add following method to PersonDataItemFactory to provide typed PersonSavableData item for data saving operation.
public override ISavableData<Person> GetSavableData(DataItem<Person> dataItem) {
return new PersonSavableData() { Data = dataItem.Data, OriginalData = dataItem.OriginalData };
}
And finally modify PeopleProvider to call backend SaveData method
public void SaveDataAsync(string objectInfo, ObservableCollection<Neon.Web.Data.ISavableData<Person>> dataList) {
PeopleServiceClient proxy = new PeopleServiceClient();
proxy.SaveDataCompleted += new EventHandler<SaveDataCompletedEventArgs>(proxy_SaveDataCompleted);
ObservableCollection<PersonSavableData> dataList2 = new ObservableCollection<PersonSavableData>();
foreach (ISavableData<Person> savableData in dataList) {
dataList2.Add(savableData as PersonSavableData);
}
proxy.SaveDataAsync(objectInfo, dataList2);
}
public event EventHandler<SaveDataCompletedEventArgs<Person>> SaveDataCompleted;
//
void proxy_SaveDataCompleted(object sender, SaveDataCompletedEventArgs e) {
ObservableCollection<ISavableData<Person>> dataList = new ObservableCollection<ISavableData<Person>>();
foreach (PersonSavableData savableData in e.Result) {
dataList.Add(savableData as ISavableData<Person>);
}
OnSaveDataCompleted(dataList);
}
protected virtual void OnSaveDataCompleted(ObservableCollection<ISavableData<Person>> dataList) {
if (null != SaveDataCompleted) {
App.Current.RootVisual.Dispatcher.BeginInvoke(() =>
SaveDataCompleted(this, new SaveDataCompletedEventArgs<Person>() { SavableDataCollection = dataList })
);
}
}
In order to save data, we need to call Save method on ObjectDataSource. ObjectDataSource provides list of all items that have changed to the SaveDataAsync method of PeopleProvider. SaveDataAsync methods in turn called SaveDataAsync method of PeopleServiceClient, resulting in async call to the PeopleService SaveData method.
Modify Page.xaml to add save button
<Grid x:Name="LayoutRoot" Background="White">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Button x:Name="SaveButton" Grid.Row="0" Content="Save" Width="100" HorizontalAlignment="Left"/>
<data:DataGrid x:Name="peopleDataGrid" AutoGenerateColumns="False" Grid.Row="1"
Margin="10" RowHeight="22" ItemsSource="{Binding DataList}" >
In Page.xaml.cs wire up Click event and call Save method
void SaveButton_Click(object sender, RoutedEventArgs e) {
((PeopleDataSource)this.DataContext).Save();
}
Compile and test application. Modify a row and Save changes. Note that row status color changes from yellow (changed) to green (saved).
=>
=>
At this point we have a complete application that can handle add, update, delete and save data back to server. It is easily modifiable by plugging in your own providers and allows for faster development. In summary, ObjectDataSource provides automated state management and lets you choose how to implement backend data store.
Please see sample application in download for automatic paging, custom sorting and additional functionality.
Steps to get started
1. Create a SilverlightApplication.
2. Use WCF or alternate means to implement service.
3. For client entity, implement IDataItemData interface
4. Implement IObjectDataProvider to automate fetch and data load process or manually fetch and load data into ObjectDataSource instance. Optionally implement IDataItemFactory to customize DataItem and/or setup custom validation.
5. Create a typed data source for declarative XAML use
Source Code: Neon, a toolkit for developing business applications with Silverlight.
Technorati Tags:
Silverlight