Building Your Own Profile Provider
The Membership and Profile systems are undoubtedly something youll want to use on new projects as is. Theyll save you a ton of work because they handle the grunt work of managing user data. There will be times, however, when you have specific needs or existing data that youd like to use in the context of these systems. Thats why ASP.NET enables you to write your own providers. Lets walk through an example of a custom Profile provider to match our Membership provider.
Just as our custom Membership provider had to inherit from and implement members of an abstract class, our custom Profile provider will do the same, this time using System.Web.Profile.ProfileProvider as the base. This provider, however, only has two properties, Name and ApplicationName. It also has an Initialize() method like the Membership provider. Listing 12.4 shows the two properties and the Initialize() method.
If youre curious about the lineage of these providers, they do share some common ground. ProfileProvider inherits from System.Configuration.SettingsProvider, which in turn inherits from System.Configuration.Provider.ProviderBase. MembershipProvider inherits directly from ProviderBase. You can see by looking at the "roots" of the class where they get their members.
Listing 12.4. The two required properties and Initialize() method of our Profile provider
C#
public class CustomProfileProvider : ProfileProvider
{
public override void Initialize(string name, NameValueCollection config)
{
_name = name;
if (config["applicationName"] == null || config["applicationName"].Trim() == ")
{
_applicationName = HttpContext.Current.Request.ApplicationPath;
}
else
{
_applicationName = config["applicationName"];
}
}
private string _name;
public override string Name
{
get { return _name; }
}
private string _applicationName;
public override string ApplicationName
{
get { return _applicationName; }
set { _applicationName = value; }
}
}
VB.NET
Public Class CustomProfileProvider
Inherits ProfileProvider
Public Overrides Sub Initialize(name As String, _
config As NameValueCollection)
_name = name
If config("applicationName") Is Nothing Or _
config("applicationName").Trim() = " Then
_applicationName = HttpContext.Current.Request.ApplicationPath
Else
_applicationName = config("applicationName")
End If
End Sub
Private _name As String
Public Overrides ReadOnly Property Name() As String
Get
Return _name
End Get
End Property
Private _applicationName As String
Public Overrides Property ApplicationName() As String
Get
Return _applicationName
End Get
Set
_applicationName = value
End Set
End Property
End Class
If this code looks familiar to you, thats probably because it isnt that different from the humble start of our custom Membership class in Listing 11.5. The difference here is that we dont have the extra properties to deal with. We set the properties in the Initialize() method, where Name is still read-only and comes from web.config, while ApplicationName is still determined by the path of the application or the application name.
As with the custom Membership provider we created in Chapter 11, we arent going to make use of the ApplicationName here. As with Membership, this property can be used to associate profile data with a specific application in a shared database.
We could provide a connection string property here as well, similar again to those used in Membership providers. The same principles apply when passing in values via attributes in the web.config provider add elements, setting them up in the provider itself via the Initialize() method.
We need to be concerned about two primary methods to get data in and out of our data store. GetPropertyValues() pulls data out of the store, while SetPropertyValues() persists them when Profile.Save() is called.Listing 12.3), you may want to store and retrieve anonymous user data. If the user is not authenticated and a call is made to the anonymous users profile, the "UserName" value returned is a Guid (in string form) used to identify the anonymous user.
The SettingsPropertyCollection is, believe it or not, a collection of SettingsProperty objects. The method will get one of these objects for every property you declared in web.config. If your provider is configured as the default provider, then any property not specifically set to use another provider (with the provider attribute) will be in this collection. If its not the default provider, then only those properties specified to use your provider will be in the collection. Grouped properties will have a name like the one youd use in the calling code. In our previous grouping example, calling for Profile.Pets.CatName would correspond to a SettingsProperty object with the name "Pets.CatName" here.
The SettingsPropertyValueCollection object should return a collection of SettingsPropertyValue objects, one for each of the SettingsProperty objects passed in to the method.
Now that youre familiar with the parameters and return value for GetPropertyValues(), you need to make some design decisions. Youll need to decide if your provider will be capable of storing any property you specify in web.config, or if it will handle only properties you specify. The default SQL provider does a good job of accommodating any property, so duplicating that functionality is a lot like reinventing the wheel. Chances are that if youre writing a custom provider, youve got very specific needs in mind, and therefore you only need to handle specific properties.
In case youre wondering, the default SQL provider stores the data in a single row for a particular user. In the aspnet_Profile table, the UserId column references an entry in the aspnet_Users table (where the name of the user, or Guid as a string for anonymous users, is stored). Two columns hold the actual property values, PropertyValuesString and PropertyValuesBinary, which store strings and binary values, respectively. The PropertyNames column stores the property name, a flag indicating whether the value is stored as a string or binary object, the starting position of the property value in either the PropertyValuesString or PropertyValuesBinary columns, and the length of the value. So if we had a binary property called "Test" and a string value called "Age," the PropertyNames column would say:
Test:B:0:40:Age:S:0:2
PropertyValuesString would say something like "21" for the age (causing the length of "2" in PropertyNames), and PropertyValuesBinary would have some binary value for test that was 40 bytes long.
Lets assume were only going to return values for specific properties, namely those we set up in Listing 12.1. "Happy" and "HairColor" are two data items associated with a particular users profile data. Our profile database table in SQL Server has three columns: Name (nvarchar), Happy (bit), and HairColor (nvarchar). Well match up these database values with our properties, as shown in Listing 12.5, our complete GetPropertyValues() method. Depending on your needs, you may want to store additional data such as a DateTime that indicates when the profile data was updated or accessed. These are handy when implementing other members in the provider, as well see later.
Listing 12.5. Our GetPropertyValues() method
C#
[View full width]
public override SettingsPropertyValueCollection GetPropertyValues(SettingsContext context,SettingsPropertyCollection ppc)
{
if ((bool)context["IsAuthenticated"] == false) throw new Exception("Anonymous profiledata is not supported by this Profile Provider.");
SettingsPropertyValueCollection settings = new SettingsPropertyValueCollection();
SqlConnection connection = new SqlConnection("my connection string");
Connection.Open();
SqlCommand command = new SqlCommand("SELECT Happy, HairColor FROM ProfileData WHERE Name= @Name", connection);
command.Parameters.AddWithValue("@Name", context["UserName"].ToString());
SqlDataReader reader = command.ExecuteReader();
bool dataAvailable = false;
if (reader.Read()) dataAvailable = true
foreach (SettingsProperty property in ppc)
{
SettingsPropertyValue value = new SettingsPropertyValue(ppc[property.Name]);
switch (property.Name)
{
case "Happy":
if (dataAvailable) value.PropertyValue = reader.GetBoolean(0);
else value.PropertyValue = false;
break;
case "HairColor":
if (dataAvailable) value.PropertyValue = reader.GetString(1);
else value.PropertyValue = ";
break;
default:
throw new Exception("This profile provider doesnt process the \" + property.Name
+ "\" profile property.");
}
settings.Add(value);
}
reader.Close();
connection.Close();
return settings;
}
VB.NET
Public Overrides Function GetPropertyValues(context As SettingsContext,_
ppc As SettingsPropertyCollection) As SettingsPropertyValueCollection
If CBool(context("IsAuthenticated")) = False Then
Throw New Exception("Anonymous profile data is not supported by this Profile Provider.")
End If
Dim settings As New SettingsPropertyValueCollection()
Dim connection As New SqlConnection("my connection string")
Connection.Open()
Dim command As New _
SqlCommand("SELECT Happy, HairColor FROM ProfileData WHERE Name=@Name",_
connection)
command.Parameters.AddWithValue("@Name", _
context("UserName").ToString())
Dim reader As SqlDataReader = command.ExecuteReader()
Dim dataAvailable As Boolean = False
If reader.Read() Then
dataAvailable = True
End If
Dim property As SettingsProperty
For Each property In ppc
Dim value As New SettingsPropertyValue(ppc([property].Name))
Select Case [property].Name
Case "Happy"
If dataAvailable Then
value.PropertyValue = reader.GetBoolean(0)
Else
value.PropertyValue = False
End If
Case "HairColor"
If dataAvailable Then
value.PropertyValue = reader.GetString(1)
Else
value.PropertyValue = "
End If
Case Else
Throw New _
Exception("This profile provider doesnt process the "" + _
[property].Name + "" profile property.")
End Select
settings.Add(value)
Next property
reader.Close()
connection.Close()
Return settings
End Function
At first glance, it looks like theres a lot going on here, but theres not. The method starts by checking the value of the "IsAuthenticated" item in the context hash, and if its false, it throws an exception. Remember that we decided not to persist anonymous data with our provider.
Next we declare a SettingsPropertyValueCollection object, which is where well put all the values we want to return.
The next bit is typical SQL code that looks for the profile data from our database, based on the "UserName" string passed in via the settings context. Its possible that the user may not have any profile data stored yet, so we create a test with the SqlDataReaders Read() method.
Finally we loop through the SettingsProperty objects passed in via the SettingsPropertyCollection parameter. First we create the SettingsPropertyValue object, based on the SettingsProperty that were going to add to the returning SettingsPropertyValueCollection. For each object, well run a switch (Select in VB.NET) block to determine which property were dealing with. For each match, well assign the value from the database if our Read() test was successful; otherwise well assign a default value. If the property name doesnt match any of our cases, well throw an exception indicating that our provider doesnt handle the particular profile property. As long as one of the items in the switch block is called, well add the new SettingsPropertyValue object to our SettingsPropertyValueCollection. When were done, we close our data connection and return the new collection. This method now acts as the plumbing between our Profile property and our data store.
SetPropertyValues() should do nearly the same thing, only in reverse. It acts as the plumbing when the Save() method of the HttpProfile object is called. Perhaps the biggest difference is that well delete the users old profile data before saving the new data. This method has no return value, but it again has the SettingsContext object and this time a SettingsPropertyValueCollection as parameters. The code is shown in Listing 12.6.
Listing 12.6. Our SetPropertyValues() method
C#
[View full width]
public override void SetPropertyValues(SettingsContext context,SettingsPropertyValueCollection ppvc)
{
SqlConnection connection = new SqlConnection("my connection string");
connection.Open();
SqlCommand command = new
SqlCommand("DELETE FROM ProfileData WHERE Name = @Name", connection);
command.Parameters.AddWithValue("@Name", context["UserName"].ToString());
command.ExecuteNonQuery();
command.CommandText = "INSERT INTO ProfileData "
+ "(Name, Happy, HairColor) VALUES (@Name, @Happy, @HairColor)";
foreach (SettingsPropertyValue propertyvalue in ppvc)
{
switch (propertyvalue.Name)
{
case "Happy":
command.Parameters.AddWithValue("@Happy", (bool)propertyvalue.PropertyValue);
break;
case "HairColor":
command.Parameters.AddWithValue("@HairColor", (string)propertyvalue.PropertyValue);
break;
default:
throw new Exception("This profile provider doesnt process the \" + propertyvalue.Name
+ "\" profile property.");
}
}
command.ExecuteNonQuery();
connection.Close();
}
VB.NET
[View full width]
Public Overrides Sub SetPropertyValues(context As SettingsContext, ppvc AsSettingsPropertyValueCollection)
Dim connection As New SqlConnection("my connection string")
connection.Open()
Dim command As New _
SqlCommand("DELETE FROM ProfileData WHERE Name = @Name", connection)
command.Parameters.AddWithValue("@Name", context("UserName").ToString())
command.ExecuteNonQuery()
command.CommandText = "INSERT INTO ProfileData (Name, Happy, HairColor) VALUES (@Name,@Happy, @HairColor)"
Dim propertyvalue As SettingsPropertyValue
For Each propertyvalue In ppvc
Select Case propertyvalue.Name
Case "Happy"
command.Parameters.AddWithValue("@Happy", CBool(propertyvalue.PropertyValue))
Case "HairColor"
command.Parameters.AddWithValue("@HairColor", CStr(propertyvalue.PropertyValue))
Case Else
Throw New Exception("This profile provider doesnt process the "" + propertyvalue.Name + "" profile property.")
End Select
Next propertyvalue
command.ExecuteNonQuery()
connection.Close()
End Sub
This method is a bit more straightforward. As before, our user is identified by the "UserName" value in the SettingsContext hash. First we delete any profile data for the user. We could also look for existing data, and if it exists, perform an UPDATE instead of an INSERT, but this requires an extra step if the data does not already exist. Next we create the command that will insert the data and add parameters to our command object by looping through the SettingsPropertyValueCollection. We get the new value from the SettingsPropertyValue objects PropertyValue properties. (Is that enough use of the word "property?") When were done, we execute the command and clean up our connection.
The remaining methods to implement in the provider get or delete profile data. These methods act as the plumbing under the static methods of the ProfileManager class. Most of these methods use ProfileInfo objects grouped in ProfileInfoCollection objects.
The ProfileInfo class does not contain any of the actual profile data, but it does contain several properties that describe it: IsAnonymous (Boolean), LastActivityDate (DateTime), LastUpdatedDate DateTime), Size (Int32), and UserName (String). The class is little more than a container class, so youre free to populate whichever proper ties you want. Instances of the class can be added to a ProfileInfoCollection object in the same way as most other collections, using its Add() method. Listing 12.7 shows a simple example of manipulating these two objects.
Listing 12.7. Using ProfileInfo with ProfileInfoCollection
C#
ProfileInfoCollection pic = new ProfileInfoCollection();
ProfileInfo info = new ProfileInfo();
info.UserName = "Jeff";
info.LastUpdatedDate = DateTime.Now;
pic.Add(info);
VB.NET
Dim pic As New ProfileInfoCollection()
Dim info As New ProfileInfo()
info.UserName = "Jeff"
info.LastUpdatedDate = DateTime.Now
pic.Add(info)
Lets look at the other eight methods required for a profile provider. If your requirements dont call for these methods, as used via ProfileManager, you can add just one line of code to these methods:
throw new NotImplementedException();
Visual Studio 2005 actually generates this line for you in each of the methods when you declare your intention to inherit from the base class.
DeleteProfiles() has two overloads, one that takes a string array of user names as a parameter, and one that takes a ProfileInfoCollection. These methods are intended to delete the profiles of those users indicated. Both return an integer indicating the number of deleted profiles.
DeleteInactiveProfiles() takes two parameters, a ProfileAuthenticationOption enumeration (whose values are All, Anonymous, or Authenticated) and a DateTime indicating the cut-off date for inactive profiles. The method returns an integer indicating the number of deleted profiles. Implementing this method requires that your profile data use some kind of date stamp that is updated any time the profile is accessed. It also requires that you differentiate between the profiles of anonymous and authenticated users. GetNumberOfInactiveProfiles() works just like your DeleteInactiveProfiles(), except that it doesnt delete the profile data.
GetAllProfiles() returns a ProfileInfoCollection and limits the number of ProfileInfo objects returned by providing a paging mechanism. Its first parameter is a ProfileAuthenticationOption value, followed by integers for the page index and page size, and an integer output parameter that indicates the total number of records. In the case of most data stores, its probably a good idea to implement some kind of server-side logic (such as a stored procedure in SQL Server) to page the results and send them back to the ASP.NET application.
Output parameters rest in a method signature like other parameters, but the difference is that they actually must have a value assigned to them before the method is finished executing. Theyre marked in C# with the out keyword, and ByRef in VB.NET. You need a variable declared when calling a method with an output value to hold the output value. Imagine you have the following method:
C#
public void OutputTest(out int totalRecords)
{
totalRecords = 32;
}
VB.NET
Public Sub OutputTest(ByRef totalRecords As Integer)
totalRecords = 32
End Sub
Before calling this method, youll have an integer declared to pass in a parameter, and when the method is finished, the value of that integer will have changed.
int x = 0;
OutputTest(out x);
Trace.Write(x.ToString()); x now has a value of 32
GetAllInactiveProfiles() works almost the same as GetAllProfiles(), except that its second parameter is a DateTime indicating the cut-off for inactive profiles. As with DeleteInactiveProfiles(), this requires that youve put some kind of activity date stamp in your profile data.
FindProfilesByUserName() and FindInactiveProfilesByUserName() also work similarly, except that they take a string parameter to search for profiles by name.
More specifics about this and the other ASP.NET providers are available in the .NET SDK documentation, including full-blown sample implementations. Simply search for the providers base class, where youll find links to the sample implementations. The documentation has details on the method signatures, return values, and the intended function of each member.