UPDATE: Jan 30 2012: Added check that entity is not itself (i.e. when user attempts to save the same entity).
UPDATE: Feb 26 2012: Added support for inherited entities, where the validated property is on the sub-class of the inheritance hierarchy.
Here is a ValidationAttribute subclass that will allow you to validate that a column doesn't have duplicates.
It's intended to be used with EF 4.1 DbContext, or with the EF4's ObjectContext.
namespace System.ComponentModel.DataAnnotations
{
#if !SILVERLIGHT
using System;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Data.Metadata.Edm;
using System.Data.Objects;
using System.Linq;
#endif
/// <summary>
/// Validates whether a value is set in another row at the same column.
/// Does not validate null empty or whitespace strings.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class UniqueAttribute : ValidationAttribute
{
#if SILVERLIGHT
/// <summary>
/// Just a body method, the ValidationAttribute requires the IsValid method to be overridden.
/// </summary>
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
return ValidationResult.Success;
}
#else
private readonly Type _ContextType;
public Type ContextType
{
get
{
return _ContextType;
}
}
//TODO: If placed in your domain, uncomment and replace MyDbContext with your domain's DbContext/ObjectContext class.
//public UniqueAttribute() : this(typeof(MyDbContext)) { }
/// <summary>
/// Initializes a new instance of <see cref="UniqueAttribute"/>.
/// </summary>
/// <param name="contextType">The type of <see cref="DbContext"/> or <see cref="ObjectContext"/> subclass that will be used to search for duplicates.</param>
public UniqueAttribute(Type contextType)
{
if (contextType == null)
throw new ArgumentNullException("contextType");
if (!contextType.IsSubclassOf(typeof(DbContext)) && !contextType.IsSubclassOf(typeof(ObjectContext)))
throw new ArgumentException("The contextType Type must be a subclass of DbContext or ObjectContext.", "contextType");
if (contextType.GetConstructor(Type.EmptyTypes) == null)
throw new ArgumentException("The contextType type must declare a public parameterless consructor.");
_ContextType = contextType;
}
/// <summary>
/// Validates the value against the matching columns in the other rows of this table.
/// Note that this method does not validate null or empty strings.
/// </summary>
/// <param name="value">The value to validate</param>
/// <param name="validationContext">The context information about the validation operation.</param>
/// <returns>An instance of the <see cref="ValidationResult"/> class.</returns>
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value == null || (value is string && string.IsNullOrWhiteSpace((string)value))) return ValidationResult.Success;
var type = validationContext.ObjectType;
var property = type.GetProperty(validationContext.MemberName);
type = property.DeclaringType;
using (var dbcontext = (IDisposable)Activator.CreateInstance(_ContextType))
{
var context = dbcontext is DbContext ? ((IObjectContextAdapter)dbcontext).ObjectContext : (ObjectContext)dbcontext;
var md = context.MetadataWorkspace;
var entityType = md.GetItems<EntityType>(DataSpace.CSpace).SingleOrDefault(et => et.Name == type.Name);
while (entityType.BaseType != null)
entityType = (EntityType)entityType.BaseType;
var objectType = typeof(object);
var isInherited = false;
var baseType = type;
while (baseType.Name != entityType.Name && baseType.BaseType != objectType)
{
baseType = baseType.BaseType;
isInherited = true;
}
var methodCreateObjectSet = typeof(ObjectContext).GetMethod("CreateObjectSet", Type.EmptyTypes).MakeGenericMethod(baseType);
var baseObjectSet = (ObjectQuery)methodCreateObjectSet.Invoke(context, new object[] { });
var objectSet = baseObjectSet;
var setType = baseObjectSet.GetType();
if (isInherited)
{
var ofType = setType.GetMethod("OfType");
ofType = ofType.MakeGenericMethod(type);
objectSet = (ObjectQuery)ofType.Invoke(baseObjectSet, null);
setType = objectSet.GetType();
}
var methodWhere = setType.GetMethod("Where");
var eSql = string.Format("it.{0} = @{0}", validationContext.MemberName);
var query = (ObjectQuery)methodWhere.Invoke(objectSet,
new object[] { eSql, new[] { new ObjectParameter(validationContext.MemberName, value) } });
var result = query.Execute(MergeOption.NoTracking).Cast<object>();
bool isValid = true;
using (var enumerator = result.GetEnumerator())
{
if (enumerator.MoveNext())
{
var nameProperty = typeof(ObjectSet<>).MakeGenericType(baseType).GetProperty("EntitySet");
var entitySet = (EntitySet)nameProperty.GetValue(baseObjectSet, null);
var entitySetName = entitySet.Name;
do
{
var current = enumerator.Current;
var curKey = context.CreateEntityKey(entitySetName, current);
var validatingKey = context.CreateEntityKey(entitySetName, validationContext.ObjectInstance);
if (curKey != validatingKey)
{
isValid = false;
break;
}
} while (enumerator.MoveNext());
}
}
return isValid ?
ValidationResult.Success :
new ValidationResult(
string.Format("There is already a '{0}' record that has its '{1}' field set to '{2}'.",
validationContext.ObjectType.Name,
validationContext.DisplayName,
value),
new[] { validationContext.MemberName });
}
}
#endif
}
}
As soon as you will be trying to call DbContext.SaveChanges in the DomainService, if one of the entities is invalidated by this attribute, the save will fail.
Since I didn't test this attribute in the LinqToEntitiesDomainService, I'm affraid that since the ObjectContext doesn't validate entities when submitting changes, you'll have to make a use of the ValidationContext property, please comment if anything is unclear or for further details.
This attribute should work just like all the other ValidationAttributes do.
When using with a Silverlight project and a WCF-RIA service, as soon as you'll try to commit the changes, it will stop and is reported back to the client who has its DataField decorated with the validation error, and since the DomainContext.SubmitChanges in the client is asynchronous, all this will happen asynchronously.
To view raw code please refer here.
Since I didn't invest in testing it in other environments (For instance SL < 5.0) and other test options/features, any comments/improvements will be very appreciated.
What's not implemented is, selecting just the ID column and the duplicate-check column. I was too lazy to invest on building the select column (getting the ID column(s) of a specific entity).
Anyway, depending on the column amount of the table, it shouldn't make such a huge difference, as this query is not supposed to return more than one row anyway, unless bypassed this validation by adding rows directly to database.
Hope this helps,
Shimmy