DocumentDBCRUDRepository
namespace: MvcControlsToolkit.Business.DocumentDB
DocumentDBCRUDRepository<M>
implements the
ICRUDRepository
interface, and offers a few more methods to build more general LinQ queries for
CosmosDB/DocumentDB. M
is the data model used to communicate with the collection
handled by the repository (see here
for more details on the way data model are used).
Its constructor is:
IDocumentDBConnection connection,
string collectionId,
Expression<Func<M, bool>> selectFilter = null,
Expression<Func<M, bool>> modificationFilter = null,
SimulateOperations simulateOperations= SimulateOperations.None)
Where:
- connection: IDocumentDBConnection
- The database connection
- collectionId: string
- The name of the DocumentdDB collection handled by the repository
- selectFilter: Expression<Func<M, bool>>
- A filter condition applied to all ICRUDRepository query operations. It is usefull to filter out all records that the currently logged user can't read.
- modificationFilter: Expression<Func<M, bool>>
- A filter condition applied to all ICRUDRepository update and delete operations. It is usefull to prevent modifications to all records that the currently logged user can't modify or delete.
- simulateOperations: SimulateOperations
-
A flag enum specifying which ICRUDRepository operations must be simulated, instead of being passed
to CosmosDB. Information about all simulated operations are available in the
SimulationResult
property immediately after the execution ofawait SaveChanges()
.
The selectFilter
and modificationFilter
conditions
may be adapted to the logged user as follows: a) Inherit your repository class from DefaultCRUDRepository
, b) In the dependency injection section of your asp.net core project, specify a factory function to create your repository instances
that takes logged user infos from the HttpContext object and pass it to the constructor of your repository class, c) in the constructor of your repository class build the "read access" and "modify access"conditions by using logged user infos
and pass them to the base constructor.
Projections to and from ViewModels/DTOs
The way data ara projected from the data model to a DTO/ViewModel need to be specified once and for all
at program start by calling the static method:
DocumentDBCRUDRepository<M>
.DeclareProjection<K, PK>(Expression<Func<M, K>> proj,
Expression<Func<K, PK> key)
Where key
is the ViewModel/DTO property working as primary key, while proj
is a select-like clause that specifies how the data model is projected
on the ViewModel/DTO. The only difference being that simple properties don't need to be included since they are inferred automatically by a same-name convention.
The same name convention supports both flattening and unflattening of objects. So for instance a xSpouse.Name
property is matched also with a SpouseName
property. Summying up just nested object, nested collections and simple properties that do not conform to the same-name convention
need to be specified.
Below an example:
.DeclareProjection
(m =>
new MainItemDTO
{
SubItems= m.SubItems.Select(l => new SubItemDTO { })
}, m => m.Id
);
Please don't forget to specify null checks on nested objects to avoid exceptions. Null checks
on nested collections are added automatically and MUS NOT BE included manually.
DeclareProjection
is typically called in the static constructor of a repository class that inherits from
DocumentDBCRUDRepository
The inverse projection is specified by calling the static method: DeclareUpdateProjection<K>(Expression<Func<M, K>> proj)
, where
K
is the DTO/ViewModel to project. Simple properties are inferred as for DeclareProjection
.
Please don't forget to specify null checks on nested objects to avoid exceptions. Null checks on nested collections are added automatically and MUS NOT BE included manually.
During database updates objects contained in any nested collection are merged with the objects contained in the associated collection in the
data model. The merge operation requires a property that works as principal key for the objects contained the nested collection
of the data model. This key property may be specified by decorating the data model collection with the CollectionKey
attribute as shown in the example below:
{
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
[CollectionKey("Id"), JsonProperty(PropertyName = "subItems")]
public ICollection<Item> SubItems { get; set; }
[JsonProperty(PropertyName = "assignedTo")]
public Person AssignedTo { get; set; }
[JsonProperty(PropertyName = "isComplete")]
public bool Completed { get; set; }
}
During the merge objects have their properties updated with the property values contained
in any object in the associated DTO collection that have their same keys. DTO objects having no associated objects
in the data model collection are added as new collection elements, while objects in the data model collection having
no associated objects in the DTO collection are removed. The removal operation may be prevented
by decorating the DTO collection with [CollectionChange(CollectionChangeMode.Add)]
.
Notes on the ICRUDRepository implementation.
The DocumentDBCRUDRepository
implementation of ICRUDRepository
has the following peculiarities:
- Grouping is not supported
-
All updates performed by the repository class are executed in parallel when calling the async
SaveChanges
method for a better performance. If some operation fails aDocumentsUpdateException<M>
is thrown that contains all exceptions thrown by the failed operations and infos on both all failed and succeeded operations. After,SaveChanges
returns all pending operations are cleared (also in case of operation failure). All failed operations may be retried by passing this exception to theTask RetryChanges(DocumentsUpdateException<M> exception)
method. - Paging is implemented by skipping elements. Namely, all keys up to the current page are retrieved, then the needed keys are skipped, and finally all documents corresponding to the selected keys are retrieved. Accordingly, paging is not enough efficient for big amount of pages. In case of big amount of pages, continuation tokens (to be described in the last section) must be preferred.
- The computation of the total number of dicuments in a paged query may be prevented by passing a negative page number (that is -3 instead of 3).
- In case the repository collection uses a partition key a document can't be updated in such a way that its partition key changes, but it must be removed and then added again.
- Since selects on nested collections are not allowed some projection operations involved in search queries are partially performed in-memory. In any case the algorithm avoid the retrieval of data that will not be used.
Handling partitions.
In case a collection uses a partition key, the corresponding data model property must be decorated with the PartitionKey
attribute.
Moreover, it must include a computed property decorated with both the CombinedKey
and the
JsonIgnore
attributes that combines both the document key and its partition key as in the example below:
{
[CombinedKey, JsonIgnore]
public string CombinedId {
get {
return
DocumentDBCRUDRepository<PItem>
.DefaultCombinedKey(Id, Name);
}
set {
string id, p;
DocumentDBCRUDRepository<PItem>
.DefaultSplitCombinedKey(value, out id, out p);
Id = id; Name = p;
} }
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
[PartitionKey]
public string Name { get; set; }
public string Description { get; set; }
[JsonProperty(PropertyName = "subItems"),
CollectionKey("Id")]
public ICollection<Item> SubItems { get; set; }
[JsonProperty(PropertyName = "assignedTo")]
public Person AssignedTo { get; set; }
[JsonProperty(PropertyName = "isComplete")]
public bool Completed { get; set; }
}
From the DTO/ViewModel perspective the combiend key defined this way, is seen as the "actual key", meaning both GetById
and Delete
operations must be passed that key.
This way the whole handling of the partition key is not passed to the presentation layer and remains "hidden" in the DB layer.
Utility methods for building custom LinQ queries.
public IQueryable<M> Table(int? pageSize=null,
object partitionKey=null,
string continuationToken=null)
public IQueryable<N> Table<N>(int? pageSize=null,
object partitionKey=null,
string continuationToken=null)
It starts a query for the data model M
handled by the repository or for another
data model N
.
- pageSize
- The required page size for paging results
- partitionKey
- The partition key if the LinQ query will be single-partition, and not cross-partition
- continuationToken
- continuation token in case the query must return another result page of a previously issued query
public async Task<IList<K>> ToList<K>(IQueryable<M> query, int toSkip=0)
public async Task<IList<K>> ToList<K, N>(IQueryable<N> query, int toSkip=0)
It finalizes query
by first projecting the results on the DTO/ViewModel K
, and then by returning a list.
The projection operation is partially performed after the transformation in list to overcome some DocumentDB
limitations on the allowed select operations. It handles paging in a classical way by skipping the documents
specified by the toSkip
parameter. The page size is specified with the initial Table
call, while the total amount of data to retrieve, that includes the ones to skip, must be specified with a Take
contained in query
.
- query
- The query to finalize.
- toSkip
- The documents to skip
public async Task<DataSequence<K, string>> ToSequence<K>(IQueryable<M> query)
public async Task<DataSequence<K, string>> ToSequence<K, N>(IQueryable<N> query)
It finalizes query
by first projecting the results on the DTO/ViewModel K
, and then by transforming them into a list.
It returns a DataSequence
class that contains both the results list and a continuation token to get another results page.
The projection operation is partially performed after the transformation in list to overcome some DocumentDB
limitations on the allowed select operations. It handles paging in a classical way by skipping the documents
specified by the toSkip
parameter. The page size is specified with the initial Table
.
- query
- The query to finalize.
public async Task<K> FirstOrDefault<K>(IQueryable<M> query)
public async Task<K> FirstOrDefault<K, N>(IQueryable<N> query)
It finalizes query
by first projecting the results on the DTO/ViewModel K
, and then by extracting the first element.
The projection operation is partially performed after the transformation in list to overcome some DocumentDB
limitations on the allowed select operations. It is usually used with a page size of 1
specified with the initial Table
.
- query
- The query to finalize.
Repository events
Developers may specify custom code to be executed immediately before each entity is created or added by overriding the empty virtual
DocumentDBCRUDRepository
methods below:
T entity, bool full, object dto)
{
}
public virtual async Task BeforeAdd(
T entity, bool full, object dto)
{
}
Where entity
is the database object created from the DTO passed to the Add/Update repository methods,
full
is the same full
parameter passed to the Add/Update repository methods, and dto
is the DTO passed to the Add/Update repository methods. The above methods are triggered also when updates and creations come from
an UpdateList
method call.
The BeforeAdd
method is very usefull when Add
is called with full
set to false. In fact a false
full
value declares that DTO doesn't contain all values needed to define a database entity and, in this case,
the BeforeAdd
method may be used to retrive adequate default values for the unspecified properties from the database.