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:

public DocumentDBCRUDRepository(
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 of await 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:

DocumentDBCRUDRepository<Item>
.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:

public class Item
{
[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:

  1. Grouping is not supported
  2. 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 a DocumentsUpdateException<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 the Task RetryChanges(DocumentsUpdateException<M> exception) method.
  3. 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.
  4. 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).
  5. 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.
  6. 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:

public class PItem
{
[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:

public virtual async Task BeforeUpdate(
    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.


Fork me on GitHub