-
Notifications
You must be signed in to change notification settings - Fork 9
Data Commands
A data command has basically only one method: ExecuteAsync, which takes an operation context as parameters and returns a promise (task) of a result. The base interface to be implemented by a command is IDataCommand, which has its generic flavor IDataCommand<TOperationContext, TResult>. However, to ease the definition of commands, a base class DataCommandBase<TOperationContext, TResult> is provided, where only the generic ExecuteAsync method should be implemented.
public interface IDataCommand
{
/// <summary>
/// Executes the data command asynchronously.
/// </summary>
/// <param name="operationContext">The operation context.</param>
/// <param name="cancellationToken">The cancellation token (optional).</param>
/// <returns>
/// A promise of a <see cref="IDataCommandResult"/>.
/// </returns>
Task<IDataCommandResult> ExecuteAsync(IDataOperationContext operationContext, CancellationToken cancellationToken = default(CancellationToken));
}
/// <summary>
/// Contract for data commands, with typed operationContext and result.
/// </summary>
/// <typeparam name="TContext">Type of the operation context.</typeparam>
/// <typeparam name="TResult">Type of the result.</typeparam>
public interface IDataCommand<in TOperationContext, TResult> : IDataCommand
where TOperationContext : IDataOperationContext
where TResult : IDataCommandResult
{
/// <summary>
/// Executes the data command asynchronously.
/// </summary>
/// <param name="operationContext">The operation context.</param>
/// <param name="cancellationToken">The cancellation token (optional).</param>
/// <returns>
/// A promise of a <see cref="IDataCommandResult"/>.
/// </returns>
Task<TResult> ExecuteAsync(TOperationContext operationContext, CancellationToken cancellationToken = default(CancellationToken));
}The data context uses composition to get an instance of the command to use, so the commands will provide an application service contract and one or more service implementations.
Multiple command implementations may be provided targeting specific data context implementations (different data context implementations may be provided for relational databases, NoSQL databases, or graph databases), so that the targeted data context finds the appropriate command to use. For example, a MongoDBFindOneCommand will indicate that it targets a MongoDBDataContext.
Note: When the data context creates the command through composition, it must get a new instance, otherwise unexpected behavior may occur. Therefore it is strongly discouraged to mark the application service contracts as shared.
Note: Together with the application service contract, the specific operation context type (where the input parameters will be provided) and the specific expected result type will be defined, if the command requires it. They must specialize the
IDataOperationContextandIDataCommandResultrespectively.
/// <summary>
/// Contract for find commands retrieving one entity based on a predicate.
/// </summary>
[AppServiceContract(AllowMultiple = true,
MetadataAttributes = new[] { typeof(DataContextTypeAttribute) })]
public interface IFindOneCommand : IDataCommand<IFindOneContext, IFindResult>
{
// AllowMultiple = true indicate that multiple implementations may be provided.
// DataContextTypeAttribute as metadata attribute indicate that
// the implementations may specify a target data context type.
}
/// <summary>
/// Interface for data operation contexts of the <see cref="IFindOneCommand"/>.
/// </summary>
public interface IFindOneContext : IDataOperationContext
{
/// <summary>
/// Gets the criteria of the entity to find.
/// </summary>
Expression Criteria { get; }
/// <summary>
/// Gets the type of the entity.
/// </summary>
Type EntityType { get; }
/// <summary>
/// Gets a value indicating whether to throw an exception if an entity is not found.
/// </summary>
bool ThrowIfNotFound { get; }
}
/// <summary>
/// Generic interface for data operation contexts of the <see cref="IFindOneCommand"/>.
/// </summary>
/// <typeparam name="TEntity">Type of the entity.</typeparam>
public interface IFindOneContext<TEntity> : IFindOneContext
{
/// <summary>
/// Gets the criteria of the entity to find.
/// </summary>
/// <remarks>
/// Overrides the untyped expression from the base interface
/// to provide LINQ-support.
/// </remarks>
new Expression<Func<TEntity, bool>> Criteria { get; }
}
/// <summary>
/// Interface for the find result.
/// </summary>
public interface IFindResult : IDataCommandResult
{
/// <summary>
/// Gets the found entity or <c>null</c> if no entity could be found.
/// </summary>
object Entity { get; }
}After the service contract was defined, the service implementing it is created. For convenience, the base class DataCommandBase<TOperationContext, TResult> may be used.
Important: Do not forget to annotate the command service with the targeted data context type. The match is not exact, but done on a compatibility basis. This means that if a data context instantiating a command may find multiple being compatible with it (target compatible types, like the base type
DataContextBase). The current strategy will choose the command targeting the most specific data context.
/// <summary>
/// Base class for find commands retrieving one result.
/// </summary>
[DataContextType(typeof(DataContextBase))]
public class FindOneCommand : DataCommandBase<IFindOneContext, IFindResult>, IFindOneCommand
{
//... implement the command execution
}
// The command below targets a very specific data context, the MongoDataContext,
// while the one above should work for all specializing the `DataContextBase`.
/// <summary>
/// Command for persisting changes targeting <see cref="MongoDataContext"/>.
/// </summary>
[DataContextType(typeof(MongoDataContext))]
public class MongoPersistChangesCommand : PersistChangesCommand
{
//... implement the command execution
}Even if we are ready with the new command, it is not very handy to use it. Here is how we would use it now:
var command = dataContext.CreateCommand<IFindOneCommand>();
var findContext = new FindOneContext
{
Criteria = criteria,
ThrowIfNotFound = false,
}
var result = await command.ExecuteAsync(findContext).PreserveThreadContext();
var foundEntity = result.Entity;
// yupee, got the entity! but it was a loooong way to get there :(.So, to achieve the simplicity of simply calling the command on the data context, the next step would be to provide an extension method to wrap up all this stuff.
public static class DataContextExtensions
{
//...
public static async Task<T> FindOneAsync<T>(
this IDataContext dataContext,
Expression<Func<T, bool>> criteria,
bool throwIfNotFound = true,
CancellationToken cancellationToken = default(CancellationToken))
where T : class
{
Requires.NotNull(dataContext, nameof(dataContext));
Requires.NotNull(criteria, nameof(criteria));
var findOneContext = new FindOneContext<T>(dataContext, criteria, throwIfNotFound);
var command = (IFindOneCommand)dataContext.CreateCommand(typeof(IFindOneCommand));
var result = await command.ExecuteAsync(findOneContext, cancellationToken).PreserveThreadContext();
return (T)result.Entity;
}
}Now we can use the defined command as if it was a method of the data context:
var foundEntity = await dataContext.FindOneAsync<Customer>(
c => c.Name == "John" && c.FamilyName == "Doe",
throwIfNotFound: false).PreserveThreadContext();
// way better :)Most data operations are by design asynchronous, but some do not need this overhead, for example because they work with data in the local cache, like marking entities for deletion or discarding the in-memory changes. For such data commands, they need to implement the ISyncDataCommand interface or, more comfortable, specialize SyncDataCommandBase.