The Planetary Docs Sample

The Planetary Docs sample is designed to provide a comprehensive example that demonstrates how an app with create, read, and update requirements is architected with the EF Core Azure Cosmos DB provider. This Blazor Server app provides search capabilities, an interface to add and update documents, and a system to store versioned snapshots. The fact that the payloads are documents with metadata that may change over time makes it ideally suited for a document database. The database uses multiple containers with different partition keys and a mechanism to provide blazing fast, inexpensive searches for specific fields. It also handles concurrency.

Get started

Here's how to get started in a few easy steps.

Clone this repo

Using your preferred tools, clone the repository. The git commmand looks like this:

git clone https://github.com/dotnet/EntityFramework.Docs

Create an Azure Cosmos DB instance

To run this demo, you will need to either run the Azure Cosmos DB emulator or create an Azure Cosmos DB account. You can read Create an Azure Cosmos DB account to learn how. Be sure to check out the option for a free account!

Choose the SQL API.

This project is configured to use the emulator "out of the box."

Initialize the database

Navigate to the PlanetaryDocsLoader project.

If you are using the emulator, make sure the emulator is running.

If you are using an Azure Cosmos DB account, update Program.cs with:

  • The Azure Cosmos DB endpoint
  • The Azure Cosmos DB key

The endpoint is the URI and the key is the Primary Key on the keys pane of your Azure Cosmos DB account in the Azure Portal.

Run the application (dotnet run from the command line). You should see status as it parses documents, loads them to the database and then runs tests. This step may take several minutes.

Configure and run the Blazor app

If you are using the emulator, the Blazor app is ready to run. If you are using an account, navigate to the PlanetaryDocs Blazor Server project and either update the CosmosSettings in the appsettings.json file, or create a new section in appsettings.Development.json and add your access key and endpoint. Run the app. You should be ready to go!

Project Details

The following features were integrated into this project.

PlanetaryDocsLoader parses the docs repository and inserts the documents into the database. It includes tests to verify the functionality is working.

PlanetaryDocs.Domain hosts the domain classes, validation logic, and signature (interface) for data access.

PlanetaryDocs.DataAccess contains the EF Core DbContext and an implementation of the data access service.

  • DocsContext
    • Has model-building code that shows how to map ownership
    • Uses value converters with JSON serialization to support primitives collection and nested complex types
    • Demonstrates use of partition keys, including how to define them for the model and how to specify them in queries
    • Provides an example of specifying the container by entity
    • Shows how to turn off the discriminator
    • Stores two entity types (aliases and tags) in the same container
    • Uses a "shadow property" to track partition keys on aliases and tags
    • Hooks into the SavingChanges event to automate the generation of audit snapshots
  • DocumentService
    • Shows various strategies for C.R.U. operations
    • Programmatically synchronizes related entities
    • Demonstrates how to handle updates with concurrency to disconnected entities
    • Uses the new IDbContextFactory<T> implementation to manage context instances

PlanetaryDocs is a Blazor Server app.

  • Examples of JavaScript interop in the TitleService, HistoryService, and MultiLineEditService.
  • Uses keyboard handlers to allow keyboard-based navigation and input on the edit page
  • Shows a generic autocomplete component with debounce built-in
  • HtmlPreview uses a phantom textarea to render an HTML preview
  • MarkDig is used to transform markdown into HTML
  • The MultiLineEdit component shows a workaround using JavaScript interop for limitations with fields that have large input values
  • The Editor component supports concurrency. If you open a document twice in separate tabs and edit in both, the second will notify that changes were made and provide the option to reset or overwrite

Your feedback is valuable! File an issue to report defects or request changes (we also accept pull requests.)

Introducing Planetary Docs

You may (or may not) know that Microsoft's official documentation runs entirely on open source. It uses markdown with some metadata enhancements to build the interactive documentation that .NET developers use daily. The hypothetical scenario for Planetary Docs is to provide a web-based tool for authoring the docs. It allows setting up the title, description, the alias of the author, assigning tags, editing markdown and previewing the HTML output.

It's planetary because Azure Cosmos DB is "planetary scale". The app provides the ability to search documents. Documents are stored under aliases and tags for fast lookup, but full text search is available as well. The app automatically audits the documents (it takes snapshots of the document anytime it is edited and provides a view of the history).

Note

Delete and restore aren't implemented.

Here's a look at the document for Document:

using System;
using System.Collections.Generic;

namespace PlanetaryDocs.Domain
{
    /// <summary>
    /// A document item.
    /// </summary>
    public class Document
    {
        /// <summary>
        /// Gets or sets the unique identifier.
        /// </summary>
        public string Uid { get; set; }

        /// <summary>
        /// Gets or sets the title.
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        /// Gets or sets the description.
        /// </summary>
        public string Description { get; set; }

        /// <summary>
        /// Gets or sets the published date.
        /// </summary>
        public DateTime PublishDate { get; set; }

        /// <summary>
        /// Gets or sets the markdown content.
        /// </summary>
        public string Markdown { get; set; }

        /// <summary>
        /// Gets or sets the generated html.
        /// </summary>
        public string Html { get; set; }

        /// <summary>
        /// Gets or sets the author's alias.
        /// </summary>
        public string AuthorAlias { get; set; }

        /// <summary>
        /// Gets or sets the list of related tags.
        /// </summary>
        public List<string> Tags { get; set; }
            = new List<string>();

        /// <summary>
        /// Gets or sets the concurrency token.
        /// </summary>
        public string ETag { get; set; }

        /// <summary>
        /// Gets the hash code.
        /// </summary>
        /// <returns>The hash code of the unique identifier.</returns>
        public override int GetHashCode() => Uid.GetHashCode();

        /// <summary>
        /// Implements equality.
        /// </summary>
        /// <param name="obj">The object to compare to.</param>
        /// <returns>A value indicating whether the unique identifiers match.</returns>
        public override bool Equals(object obj) =>
            obj is Document document && document.Uid == Uid;

        /// <summary>
        /// Gets the string representation.
        /// </summary>
        /// <returns>The string representation.</returns>
        public override string ToString() =>
            $"Document {Uid} by {AuthorAlias} with {Tags.Count} tags: {Title}.";
    }
}

For faster lookups, the DocumentSummary class contains some basic information about the document.

namespace PlanetaryDocs.Domain
{
    /// <summary>
    /// Represents a summary of a document.
    /// </summary>
    public class DocumentSummary
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="DocumentSummary"/> class.
        /// </summary>
        public DocumentSummary()
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="DocumentSummary"/> class
        /// and initializes it with the <see cref="Document"/>.
        /// </summary>
        /// <param name="doc">The <see cref="Document"/> to summarize.</param>
        public DocumentSummary(Document doc)
        {
            Uid = doc.Uid;
            Title = doc.Title;
            AuthorAlias = doc.AuthorAlias;
        }

        /// <summary>
        /// Gets or sets the unique id of the <see cref="Document"/>.
        /// </summary>
        public string Uid { get; set; }

        /// <summary>
        /// Gets or sets the title.
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        /// Gets or sets the alias of the author.
        /// </summary>
        public string AuthorAlias { get; set; }

        /// <summary>
        /// Gets the hash code.
        /// </summary>
        /// <returns>The hash code of the document identifier.</returns>
        public override int GetHashCode() => Uid.GetHashCode();

        /// <summary>
        /// Implements equality.
        /// </summary>
        /// <param name="obj">The object to compare to.</param>
        /// <returns>A value indicating whether the unique identifiers match.</returns>
        public override bool Equals(object obj) =>
            obj is DocumentSummary ds && ds.Uid == Uid;

        /// <summary>
        /// Gets the string representation.
        /// </summary>
        /// <returns>The string representation.</returns>
        public override string ToString() => $"Summary for {Uid} by {AuthorAlias}: {Title}.";
    }
}

This is used by both Author and Tag. They look pretty similar. Here's the Tag code:

using System.Collections.Generic;

namespace PlanetaryDocs.Domain
{
    /// <summary>
    /// A tag.
    /// </summary>
    public class Tag : IDocSummaries
    {
        /// <summary>
        /// Gets or sets the name of the tag.
        /// </summary>
        public string TagName { get; set; }

        /// <summary>
        /// Gets or sets a summary of documents with the tag.
        /// </summary>
        public List<DocumentSummary> Documents { get; set; }
            = new List<DocumentSummary>();

        /// <summary>
        /// Gets or sets the concurrency token.
        /// </summary>
        public string ETag { get; set; }

        /// <summary>
        /// Gets the hash code.
        /// </summary>
        /// <returns>The hash code of the tag name.</returns>
        public override int GetHashCode() => TagName.GetHashCode();

        /// <summary>
        /// Implements equality.
        /// </summary>
        /// <param name="obj">The object to compare to.</param>
        /// <returns>A value indicating whether the tag names match.</returns>
        public override bool Equals(object obj) =>
            obj is Tag tag && tag.TagName == TagName;

        /// <summary>
        /// Gets the string representation.
        /// </summary>
        /// <returns>The string representation.</returns>
        public override string ToString() =>
            $"Tag {TagName} tagged by {Documents.Count} documents.";
    }
}

The ETag property is implemented on the model so it can be sent around the app and maintain the value (as opposed to using a shadow property ). The ETag is used for concurrency in Azure Cosmos DB. Concurrency support is implemented in the sample app. To test it, try opening the same document in two tabs, then update one and save it, and finally update the other and save it.

People often struggle with disconnected entities in EF Core. The pattern is used in this app to provide an example. It's not necessary in Blazor Server, but makes it easier to scale the app. The alternative approach is to track the state of the entity with EF Core's change tracker. The change tracker would enable you to drop the ETag property and use a shadow property instead.

Finally, there is the DocumentAudit document.

using System;
using System.Text.Json;

namespace PlanetaryDocs.Domain
{
    /// <summary>
    /// Represents a snapshot of the document.
    /// </summary>
    public class DocumentAudit
    {
        /// <summary>
        /// Initializes a new instance of the <see cref="DocumentAudit"/> class.
        /// </summary>
        public DocumentAudit()
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="DocumentAudit"/> class
        /// and configures it with the <see cref="Document"/> passed in.
        /// </summary>
        /// <param name="document">The document to audit.</param>
        public DocumentAudit(Document document)
        {
            Id = Guid.NewGuid();
            Uid = document.Uid;
            Document = JsonSerializer.Serialize(document);
            Timestamp = DateTimeOffset.UtcNow;
        }

        /// <summary>
        /// Gets or sets a unique identifier.
        /// </summary>
        public Guid Id { get; set; }

        /// <summary>
        /// Gets or sets the identifier of the document.
        /// </summary>
        public string Uid { get; set; }

        /// <summary>
        /// Gets or sets the timestamp of the audit.
        /// </summary>
        public DateTimeOffset Timestamp { get; set; }

        /// <summary>
        /// Gets or sets the JSON serialized snapshot.
        /// </summary>
        public string Document { get; set; }

        /// <summary>
        /// Deserializes the snapshot.
        /// </summary>
        /// <returns>The <see cref="Document"/> snapshot.</returns>
        public Document GetDocumentSnapshot() =>
            JsonSerializer.Deserialize<Document>(Document);
    }
}

Ideally, the Document snapshot would be a proper property instead of a string. This is one of the EF Core Azure Cosmos DB provider limitations EF Core currently has. There is not a way for Document to do double-duty as both a standalone entity and an "owned" entity. If you want the user to be able to search on properties in the historical document, you could either add those properties to the DocumentAudit class to be automatically indexed, or make a DocumentSnapshot class that shares the same properties but is configured as "owned" by the DocumentAudit parent.

Azure Cosmos DB setup

The strategy for the data store is to use three containers.

One container named Documents is dedicated exclusively to documents. They are partitioned by id. That's one partition per document. Here's the rationale.

The audits are stored in a container named Audits. The partition key is the document id, so all histories are stored in the same partition. This allows for fast, single-partition queries over historical data.

Finally, there is some metadata that is stored in Meta. The partition key is the meta data type, either Author or Tag. The metadata contains summaries of the related documents. If the user wants to search for documents with tag x the app doesn't have to scan all documents. Instead, it reads the document for tag x that contains a collection of the related documents it is tagged in. This approach is fast for read but does require some additional work on writes and updates that will be covered later.

Entity Framework Core

The DbContext for Planetary Docs is named DocsContext in the PlanetaryDocs.DataAccess project. It has a constructor that takes a DbContextOptions<DocsContext> parameter and passes it to the base class to enable run-time configuration.

public DocsContext(DbContextOptions<DocsContext> options)
    : base(options) =>
        SavingChanges += DocsContext_SavingChanges;

The DbSet<> generic type is used to specify the classes that should be persisted.

/// <summary>
/// Gets or sets the audits collection.
/// </summary>
public DbSet<DocumentAudit> Audits { get; set; }

/// <summary>
/// Gets or sets the documents collection.
/// </summary>
public DbSet<Document> Documents { get; set; }

/// <summary>
/// Gets or sets the tags collection.
/// </summary>
public DbSet<Tag> Tags { get; set; }

/// <summary>
/// Gets or sets the authors collection.
/// </summary>
public DbSet<Author> Authors { get; set; }

A few helper methods on the DbContext class make it easier to search for and assign metadata. Both metadata items use a string-based key and specify the type as the partition key. This enables a generic strategy to find records:

public async ValueTask<T> FindMetaAsync<T>(string key)
    where T : class, IDocSummaries
{
    var partitionKey = ComputePartitionKey<T>();
    try
    {
        return await FindAsync<T>(key, partitionKey);
    }
    catch (CosmosException ce)
    {
        if (ce.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        throw;
    }
}

FindAsync (the existing method on the base DbContext that is shipped as part of EF Core) doesn't require closing the type to specify the key. It takes it as an object parameter and applies it based on the internal representation of the model.

The entities are configured in the OnModelCreating overload. Here's the first configuration for DocumentAudit.

modelBuilder.Entity<DocumentAudit>()
    .HasNoDiscriminator()
    .ToContainer(nameof(Audits))
    .HasPartitionKey(da => da.Uid)
    .HasKey(da => new { da.Id, da.Uid });

This configuration informs EF Core that...

  • There will only be one type stored in the table, so there is no need for a discriminator to distinguish types.
  • The documents should be stored in a container named Audits.
  • The partition key is the document id.
  • The access key is the unique identifier of the audit combined with the partition key (the unique identifier of the document).

Next, the Document is configured:

var docModel = modelBuilder.Entity<Document>();

docModel.ToContainer(nameof(Documents))
    .HasNoDiscriminator()
    .HasKey(d => d.Uid);

docModel.HasPartitionKey(d => d.Uid)
    .Property(p => p.ETag)
    .IsETagConcurrency();

Here we specify a few more details, such as how the ETag property should be mapped:

var tagModel = modelBuilder.Entity<Tag>();

tagModel.Property<string>(PartitionKey);

tagModel.HasPartitionKey(PartitionKey);

tagModel.ToContainer(Meta)
    .HasKey(nameof(Tag.TagName), PartitionKey);

tagModel.Property(t => t.ETag)
    .IsETagConcurrency();

The partition key is configured as a shadow property. Unlike the ETag property, the partition key is fixed and therefore doesn't have to live on the model. Read this to learn more about models in EF Core: Creating and configuring a model.

The SaveChanges event is used that to automatically insert a document snapshot any time a document is inserted or updated. Every time changes are saved and the event is fired, the ChangeTracker is queried to find an Document entities that were added or updated. An audit entry is inserted for each one.

private void DocsContext_SavingChanges(
    object sender,
    SavingChangesEventArgs e)
{
    var entries = ChangeTracker.Entries<Document>()
        .Where(
            e => e.State == EntityState.Added ||
            e.State == EntityState.Modified)
        .Select(e => e.Entity)
        .ToList();

    foreach (var docEntry in entries)
    {
        Audits.Add(new DocumentAudit(docEntry));
    }
}

Doing it this way will ensure audits are generated even if you build other apps that share the same DbContext.

The Data Service

A common question is whether developers should use the repository pattern with EF Core, and the answer is, "It depends." To the extent that the DbContext is testable and can be interfaced for mocking, there are many cases when using it directly is perfectly fine. Whether or not you specifically use the repository pattern, adding a data access layer often makes sense when there are database-related tasks to do outside of the EF Core functionality. In this example, there is database-related logic that makes more sense to isolate rather than bloating the DbContext, so it is implemented in DocumentService.

The service constructor is passed a DbContext factory. This is provided by EF Core to easily create new contexts using your preferred configuration. The app uses a "context per operation" rather than using long-lived contexts and change tracking. Here's the configuration to grab the settings and tell the factory to make contexts that connect to Azure Cosmos DB. The factory is then automatically injected into the service.

services.Configure<CosmosSettings>(
    Configuration.GetSection(nameof(CosmosSettings)));
services.AddDbContextFactory<DocsContext>(
   (IServiceProvider sp, DbContextOptionsBuilder opts) =>
   {
       var cosmosSettings = sp
           .GetRequiredService<IOptions<CosmosSettings>>()
           .Value;

       opts.UseCosmos(
           cosmosSettings.EndPoint,
           cosmosSettings.AccessKey,
           nameof(DocsContext));
   });

services.AddScoped<IDocumentService, DocumentService>();

Using this pattern demonstrates disconnected entities and also builds some resiliency against the case when your Blazor SignalR circuit may break.

Load a document

The document load is intended to get a snapshot that isn't tracked for changes because those will be sent in a separate operation. The main requirement is to set the partition key.

private static async Task<Document> LoadDocNoTrackingAsync(
DocsContext context, Document document) =>
    await context.Documents
        .WithPartitionKey(document.Uid)
        .AsNoTracking()
        .SingleOrDefaultAsync(d => d.Uid == document.Uid);

Query documents

The document query allows the user to search on text anywhere in the document and to further filter by author and/or tag. The pseudo code looks like this:

  • If there is a tag, load the tag and use the document summary list as the result set
    • If there is also an author, load the author and filter the results to the intersection of results between tag and author
      • If there is text, load the documents that match the text then filter the results to the author and tag intersection
    • If there is also text, load the documents that match the text then filter the results to the tag results
  • Else if there is an author, load the author and filter the results to the document summary list as the result set
    • If there is text, load the documents that match the text then filter the results to the author results
  • Else load the documents that match the text

Performance-wise, a tag and/or author-based search only requires one or two documents to be loaded. A text search always loads matching documents and then further filters the list based on the existing documents, so it is significantly slower (but still fast).

Here's the implementation. Note the HashSet works due to the Equals and GetHashCode overrides:

public async Task<List<DocumentSummary>> QueryDocumentsAsync(
    string searchText,
    string authorAlias,
    string tag)
{
    using var context = factory.CreateDbContext();

    var result = new HashSet<DocumentSummary>();

    var partialResults = false;

    if (!string.IsNullOrWhiteSpace(authorAlias))
    {
        partialResults = true;
        var author = await context.FindMetaAsync<Author>(authorAlias);
        foreach (var ds in author.Documents)
        {
            result.Add(ds);
        }
    }

    if (!string.IsNullOrWhiteSpace(tag))
    {
        var tagEntity = await context.FindMetaAsync<Tag>(tag);

        var resultSet =
            Enumerable.Empty<DocumentSummary>();

        // alias _AND_ tag
        if (partialResults)
        {
            resultSet = result.Intersect(tagEntity.Documents);
        }
        else
        {
            resultSet = tagEntity.Documents;
        }

        result.Clear();

        foreach (var docSummary in resultSet)
        {
            result.Add(docSummary);
        }

        partialResults = true;
    }

    // nothing more to do?
    if (string.IsNullOrWhiteSpace(searchText))
    {
        return result.OrderBy(r => r.Title).ToList();
    }

    // no list to filter further
    if (partialResults && result.Count < 1)
    {
        return result.ToList();
    }

    // find documents that match
    var documents = await context.Documents.Where(
        d => d.Title.Contains(searchText) ||
        d.Description.Contains(searchText) ||
        d.Markdown.Contains(searchText))
        .ToListAsync();

    // now only intersect with alias/tag constraints
    if (partialResults)
    {
        var uids = result.Select(ds => ds.Uid).ToList();
        documents = documents.Where(d => uids.Contains(d.Uid))
            .ToList();
    }

    return documents.Select(d => new DocumentSummary(d))
         .OrderBy(ds => ds.Title).ToList();
}

Create a document

Ordinarily, creating a document with EF Core would be as easy as:

context.Add(document);
await context.SaveChangesAsync();

For PlanetaryDocs however, the document can have associated tags and an author. These have summaries that must be updated explicitly because there are no formal relationships.

Note

This example uses code to keep documents in sync. If the database is used by multiple applications and services, it may make more sense to implement the logic at the database level and use triggers and stored procedures instead.

A generic method handles keeping the documents in sync. The pseudo code is the same whether it is for an author or a tag:

  • If the document was inserted or updated
    • A new document will result in "author changed" and "tags added"
    • If the author was changed or a tag removed
      • Load the metadata document for the old author or removed tag
      • Remove the document from the summary list
    • If the author was changed
      • Load the metadata document for the new author
      • Add the document to the summary list
        • Load all tags for the model
        • Update the author in the summary list for each tag
    • If tags were added
      • If tag exists
        • Load the metadata document for the tag
        • Add the document to the summary list
      • Else
        • Create a new tag with the document in the summary list
    • If the document was updated and the title changed
      • Load the metadata for the existing author and/or tags
      • Update the title in the summary list

This algorithm is an example of how EF Core shines. All of these manipulations can happen in a single pass. If a tag is referenced multiple times, it is only ever loaded once. The final call to save changes will commit all changes including inserts.

Here's the code for handling changes to tags that is called as part of the insert process:

private static async Task HandleTagsAsync(
    DocsContext context,
    Document document,
    bool authorChanged)
{
    var refDoc = await LoadDocNoTrackingAsync(context, document);

    // did the title change?
    var updatedTitle = refDoc != null && refDoc.Title != document.Title;

    // tags removed need summary taken away
    if (refDoc != null)
    {
        var removed = refDoc.Tags.Where(
            t => !document.Tags.Any(dt => dt == t));

        foreach (var removedTag in removed)
        {
            var tag = await context.FindMetaAsync<Tag>(removedTag);

            if (tag != null)
            {
                var docSummary =
                    tag.Documents.Find(
                        d => d.Uid == document.Uid);

                if (docSummary != null)
                {
                    tag.Documents.Remove(docSummary);
                    context.Entry(tag).State = EntityState.Modified;
                }
            }
        }
    }

    // figure out new tags
    var tagsAdded = refDoc == null ?
        document.Tags : document.Tags.Where(
            t => !refDoc.Tags.Any(rt => rt == t));

    // do existing tags need title updated?
    if (updatedTitle || authorChanged)
    {
        // added ones will be handled later
        var tagsToChange = document.Tags.Except(tagsAdded);

        foreach (var tagName in tagsToChange)
        {
            var tag = await context.FindMetaAsync<Tag>(tagName);
            var ds = tag.Documents.SingleOrDefault(ds => ds.Uid == document.Uid);
            if (ds != null)
            {
                ds.Title = document.Title;
                ds.AuthorAlias = document.AuthorAlias;
                context.Entry(tag).State = EntityState.Modified;
            }
        }
    }

    // brand new tags (for the document)
    foreach (var tagAdded in tagsAdded)
    {
        var tag = await context.FindMetaAsync<Tag>(tagAdded);

        // new tag (overall)
        if (tag == null)
        {
            tag = new Tag { TagName = tagAdded };
            context.SetPartitionKey(tag);
            context.Add(tag);
        }
        else
        {
            context.Entry(tag).State = EntityState.Modified;
        }

        // either way, add the document summary
        tag.Documents.Add(new DocumentSummary(document));
    }
}

The algorithm as implemented works for inserts, updates, and deletes.

Update a document

Now that the metadata sync has been implemented, the update code is simply:

public async Task UpdateDocumentAsync(Document document)
{
    using var context = factory.CreateDbContext();

    await HandleMetaAsync(context, document);

    context.Update(document);

    await context.SaveChangesAsync();
}

Concurrency works in this scenario because we persist the loaded version of the entity in the ETag property.

Delete a document

The delete code uses a simplified algorithm to remove existing tag and author references.

public async Task DeleteDocumentAsync(string uid)
{
    using var context = factory.CreateDbContext();
    var docToDelete = await LoadDocumentAsync(uid);
    var author = await context.FindMetaAsync<Author>(docToDelete.AuthorAlias);
    var summary = author.Documents.Find(d => d.Uid == uid);
    if (summary != null)
    {
        author.Documents.Remove(summary);
        context.Update(author);
    }

    foreach (var tag in docToDelete.Tags)
    {
        var tagEntity = await context.FindMetaAsync<Tag>(tag);
        var tagSummary = tagEntity.Documents.Find(d => d.Uid == uid);
        if (tagSummary != null)
        {
            tagEntity.Documents.Remove(tagSummary);
            context.Update(tagEntity);
        }
    }

    context.Remove(docToDelete);
    await context.SaveChangesAsync();
}

Search metadata (tags or authors)

Finding tags or authors that match a text string is a straightforward query. The key is to improve performance and reduce the cost of the query by making it a single partition query.

public async Task<List<string>> SearchAuthorsAsync(string searchText)
{
    using var context = factory.CreateDbContext();
    var partitionKey = DocsContext.ComputePartitionKey<Author>();
    return (await context.Authors
        .WithPartitionKey(partitionKey)
        .Select(a => a.Alias)
        .ToListAsync())
        .Where(
            a => a.Contains(searchText, System.StringComparison.InvariantCultureIgnoreCase))
        .OrderBy(a => a)
        .ToList();
}

The ComputePartitionKey method returns the simple type name as the partition. The authors list is not long, so the code pulls the aliases first, then applies an in-memory filter for the contains logic.

Deal with document audits

The last set of APIs deal with the automatically generated audits. This method loads document audits then projects them onto a summary. The projection is not done in the query because it requires deserializing the snapshot. Instead, the list of audits is obtained, then snapshots are deserialized to pull out the relevant data to display such as title and author.

public async Task<List<DocumentAuditSummary>> LoadDocumentHistoryAsync(string uid)
{
    using var context = factory.CreateDbContext();
    return (await context.Audits
        .WithPartitionKey(uid)
        .Where(da => da.Uid == uid)
        .ToListAsync())
        .Select(da => new DocumentAuditSummary(da))
        .OrderBy(das => das.Timestamp)
        .ToList();
}

The ToListAsync materializes the query results and everything after is manipulated in memory.

The app also lets you review an audit record using the same viewer control that live documents use. A method loads the audit, materializes the snapshot and returns a Document entity for the view to use.

public async Task<Document> LoadDocumentSnapshotAsync(System.Guid guid, string uid)
{
    using var context = factory.CreateDbContext();
    try
    {
        var audit = await context.FindAsync<DocumentAudit>(guid, uid);
        return audit.GetDocumentSnapshot();
    }
    catch (CosmosException ce)
    {
        if (ce.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        throw;
    }
}

Finally, although you can delete a record, the audits remain. The web app doesn't implement this yet, but it is implemented in the data service. The steps are simply deserialize the requested version and insert it.

public async Task<Document> RestoreDocumentAsync(Guid id, string uid)
{
    var snapshot = await LoadDocumentSnapshotAsync(id, uid);
    await InsertDocumentAsync(snapshot);
    return await LoadDocumentAsync(uid);
}

Conclusion

The goal behind the sample is to provide some guidance for using the EF Core Azure Cosmos DB provider and to demonstrate the areas it shines. Please file an issue with feedback or suggestions. We accept pull requests, so if you want to implement missing functionality or see an area of improvement, let us know!