GitHub Copilot is now available for free

No trial. No credit card required. Just your GitHub account.

November 2nd, 2023
heart9 reactions

Trying out MongoDB with EF Core using Testcontainers

Helping developers use both relationaland non-relational databases effectively was one of the original tenets of EF Core. To this end, there has been anEF Core database provider for Azure Cosmos DB document databases for many years now. Recently, the EF Core team has been collaborating with engineers fromMongoDB to bring support for MongoDB to EF Core. The initial result of this collaboration is the firstpreview release of the MongoDB provider for EF Core.

In this post, we will try outthe MongoDB provider for EF Core by using it to:

  • Map a C# object model to documents in a MongoDB database
  • Use EF to save some documents to the database
  • Write LINQ queries to retrieve documents from the database
  • Make changes to a document and use EF’s change tracking to update the document

The code shown in this postcan be found on GitHub.

Testcontainers

It’s very easy toget a MongoDB database in the cloud that you can use to try things out. However,Testcontainers is another way to test code with different database systems which is particularly suited to:

  • Running automated tests against the database
  • Creating standalone reproductions when reporting issues
  • Trying out new things with minimal setup

Testcontainers are distributed as NuGet packages that take care of running a container containing a configured ready-to-use database system. The containers use Docker or a Docker-alternative to run, so this may need to be installed on your machine if you don’t already have it. SeeWelcome to Testcontainers for .NET! for more details. Other than starting Docker, you don’t need to do anything else except import the NuGet package.

The C# project

We’ll use a simple console application to try out MongoDB with EF Core. This project needs two package references:

The fullcsproj file looks like this:

<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>        <OutputType>Exe</OutputType>        <TargetFramework>net7.0</TargetFramework>        <ImplicitUsings>enable</ImplicitUsings>        <Nullable>enable</Nullable>        <RootNamespace />    </PropertyGroup>    <ItemGroup>        <PackageReference Include="Testcontainers.MongoDB" Version="3.5.0" />        <PackageReference Include="MongoDB.EntityFrameworkCore" Version="7.0.0-preview.1" />    </ItemGroup></Project>

Remember, the full project is available todownload from GitHUb.

The object model

We’ll map a simple object model of customers and their addresses:

public class Customer{    public Guid Id { get; set; }    public required string Name { get; set; }    public required Species Species { get; set; }    public required ContactInfo ContactInfo { get; set; }}public class ContactInfo{    public required Address ShippingAddress { get; set; }    public Address? BillingAddress { get; set; }    public required PhoneNumbers Phones { get; set; }}public class PhoneNumbers{    public PhoneNumber? HomePhone { get; set; }    public PhoneNumber? WorkPhone { get; set; }    public PhoneNumber? MobilePhone { get; set; }}public class PhoneNumber{    public required int CountryCode { get; set; }    public required string Number { get; set; }}public class Address{    public required string Line1 { get; set; }    public string? Line2 { get; set; }    public string? Line3 { get; set; }    public required string City { get; set; }    public required string Country { get; set; }    public required string PostalCode { get; set; }}public enum Species{    Human,    Dog,    Cat}

Since MongoDB works with documents, we’re going to map this model to a top level Customer document, with the addresses and phone numbers embedded in this document. We’ll see how to do this in the next section.

Creating the EF model

EF works bybuilding a model of the mapped CLR types, such as those forCustomer, etc. in the previous section. This model defines the relationships between types in the model, as well as how each type maps to the database.

Luckily there is not much to do here, since EF uses a set of model building conventions that generate a model based on input from both the model types and the database provider. This means that for relational databases, each type gets mapped to a different table by convention. For document databases like Azure CosmosDB and now MongoDB, only the top-level type (Customer in our example) is mapped to its own document. Other types referenced from the top-level types are, by-convention, included in the main document.

This means that the only thing EF needs to know to build a model is the top-level type, and that the MongoDB provider should be used. We do this by defining a type that extends fromDbContext. For example:

public class CustomersContext : DbContext{    private readonly MongoClient _client;    public CustomersContext(MongoClient client)    {        _client = client;    }    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)        => optionsBuilder.UseMongoDB(_client, "efsample");    public DbSet<Customer> Customers => Set<Customer>();}

In thisDbContext class:

  • UseMongoDB is called, passing in the client driver and the database name. This tells EF Core to use the MongoDB provider when building the model and accessing the database.
  • ADbSet<Customer> property that defines the top-level type for which documents should be modeled.

We’ll see later how to create theMongoClient instance and use theDbContext. When we do, examining themodel DebugView shows this:

Model:   EntityType: ContactInfo Owned    Properties:      CustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw    Navigations:      BillingAddress (Address) ToDependent ContactInfo.BillingAddress#Address (Address)      Phones (PhoneNumbers) ToDependent PhoneNumbers      ShippingAddress (Address) ToDependent ContactInfo.ShippingAddress#Address (Address)    Keys:      CustomerId PK    Foreign keys:      ContactInfo {'CustomerId'} -> Customer {'Id'} Unique Ownership ToDependent: ContactInfo Cascade  EntityType: ContactInfo.BillingAddress#Address (Address) CLR Type: Address Owned    Properties:      ContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw      City (string) Required      Country (string) Required      Line1 (string) Required      Line2 (string)      Line3 (string)      PostalCode (string) Required    Keys:      ContactInfoCustomerId PK    Foreign keys:      ContactInfo.BillingAddress#Address (Address) {'ContactInfoCustomerId'} -> ContactInfo {'CustomerId'} Unique Ownership ToDependent: BillingAddress Cascade  EntityType: ContactInfo.ShippingAddress#Address (Address) CLR Type: Address Owned    Properties:      ContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw      City (string) Required      Country (string) Required      Line1 (string) Required      Line2 (string)      Line3 (string)      PostalCode (string) Required    Keys:      ContactInfoCustomerId PK    Foreign keys:      ContactInfo.ShippingAddress#Address (Address) {'ContactInfoCustomerId'} -> ContactInfo {'CustomerId'} Unique Ownership ToDependent: ShippingAddress Cascade  EntityType: Customer    Properties:      Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd      Name (string) Required      Species (Species) Required    Navigations:      ContactInfo (ContactInfo) ToDependent ContactInfo    Keys:      Id PK  EntityType: PhoneNumbers Owned    Properties:      ContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw    Navigations:      HomePhone (PhoneNumber) ToDependent PhoneNumbers.HomePhone#PhoneNumber (PhoneNumber)      MobilePhone (PhoneNumber) ToDependent PhoneNumbers.MobilePhone#PhoneNumber (PhoneNumber)      WorkPhone (PhoneNumber) ToDependent PhoneNumbers.WorkPhone#PhoneNumber (PhoneNumber)    Keys:      ContactInfoCustomerId PK    Foreign keys:      PhoneNumbers {'ContactInfoCustomerId'} -> ContactInfo {'CustomerId'} Unique Ownership ToDependent: Phones Cascade  EntityType: PhoneNumbers.HomePhone#PhoneNumber (PhoneNumber) CLR Type: PhoneNumber Owned    Properties:      PhoneNumbersContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw      CountryCode (int) Required      Number (string) Required    Keys:      PhoneNumbersContactInfoCustomerId PK    Foreign keys:      PhoneNumbers.HomePhone#PhoneNumber (PhoneNumber) {'PhoneNumbersContactInfoCustomerId'} -> PhoneNumbers {'ContactInfoCustomerId'} Unique Ownership ToDependent: HomePhone Cascade  EntityType: PhoneNumbers.MobilePhone#PhoneNumber (PhoneNumber) CLR Type: PhoneNumber Owned    Properties:      PhoneNumbersContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw      CountryCode (int) Required      Number (string) Required    Keys:      PhoneNumbersContactInfoCustomerId PK    Foreign keys:      PhoneNumbers.MobilePhone#PhoneNumber (PhoneNumber) {'PhoneNumbersContactInfoCustomerId'} -> PhoneNumbers {'ContactInfoCustomerId'} Unique Ownership ToDependent: MobilePhone Cascade  EntityType: PhoneNumbers.WorkPhone#PhoneNumber (PhoneNumber) CLR Type: PhoneNumber Owned    Properties:      PhoneNumbersContactInfoCustomerId (no field, Guid) Shadow Required PK FK AfterSave:Throw      CountryCode (int) Required      Number (string) Required    Keys:      PhoneNumbersContactInfoCustomerId PK    Foreign keys:      PhoneNumbers.WorkPhone#PhoneNumber (PhoneNumber) {'PhoneNumbersContactInfoCustomerId'} -> PhoneNumbers {'ContactInfoCustomerId'} Unique Ownership ToDependent: WorkPhone Cascade

Looking at this model, it can be seen that EF createdowned entity types for theContactInfo,Address,PhoneNumber andPhoneNumbers types, even though only theCustomer type was referenced directly from theDbContext. These other types were discovered and configured by the model-building conventions.

Create the MongoDB test container

We now have a model and aDbContext. Next we need an actual MongoDB database, and this is where Testcontainers come in. There are Testcontainers available for many different types of database, and they all work in a very similar way. That is, a container is created using the appropriateDbBuilder, and then that container is started. For example:

await using var mongoContainer = new MongoDbBuilder()    .WithImage("mongo:6.0")    .Build();await mongoContainer.StartAsync();

And that’s it! We now have a configured, clean MongoDB instance running locally with which we can do what we wish, before just throwing it away.

Save data to MongoDB

Let’s use EF Core to write some data to the MongoDB database. To do this, we’ll need to create aDbContext instance, and for this we need aMongoClient instance from the underlying MongoDB driver. Often, in a real app, theMongoClient instance and theDbContext instance will be obtained using dependency injection. For the sake of simplicity, we’ll justnew them up here:

var mongoClient = new MongoClient(mongoContainer.GetConnectionString());await using (var context = new CustomersContext(mongoClient)){    // ...}

Notice that the Testcontainer instance provides the connection string we need to connect to our MongoDB test database.

To save a newCustomer document, we’ll useAdd to start tracking the document, and then callSaveChangesAsync to insert it into the database.

await using (var context = new CustomersContext(mongoClient)){    var customer = new Customer    {        Name = "Willow",        Species = Species.Dog,        ContactInfo = new()        {            ShippingAddress = new()            {                Line1 = "Barking Gate",                Line2 = "Chalk Road",                City = "Walpole St Peter",                Country = "UK",                PostalCode = "PE14 7QQ"            },            BillingAddress = new()            {                Line1 = "15a Main St",                City = "Ailsworth",                Country = "UK",                PostalCode = "PE5 7AF"            },            Phones = new()            {                HomePhone = new() { CountryCode = 44, Number = "7877 555 555" },                MobilePhone = new() { CountryCode = 1, Number = "(555) 2345-678" },                WorkPhone = new() { CountryCode = 1, Number = "(555) 2345-678" }            }        }    };    context.Add(customer);    await context.SaveChangesAsync();}

If we look at the JSON (actually,BSON, which is a more efficient binary representation for JSON documents) document created in the database, we can see it contains nested documents for all the contact information. This is different from what EF Core would do for a relational database, where each type would have been mapped to its own top-level table.

{  "_id": "CSUUID(\"9a97fd67-515f-4586-a024-cf82336fc64f\")",  "Name": "Willow",  "Species": 1,  "ContactInfo": {    "BillingAddress": {      "City": "Ailsworth",      "Country": "UK",      "Line1": "15a Main St",      "Line2": null,      "Line3": null,      "PostalCode": "PE5 7AF"    },    "Phones": {      "HomePhone": {        "CountryCode": 44,        "Number": "7877 555 555"      },      "MobilePhone": {        "CountryCode": 1,        "Number": "(555) 2345-678"      },      "WorkPhone": {        "CountryCode": 1,        "Number": "(555) 2345-678"      }    },    "ShippingAddress": {      "City": "Walpole St Peter",      "Country": "UK",      "Line1": "Barking Gate",      "Line2": "Chalk Road",      "Line3": null,      "PostalCode": "PE14 7QQ"    }  }}

Using LINQ queries

EF Core supportsLINQ for querying data. For example, to query a single customer:

using (var context = new CustomersContext(mongoClient)){    var customer = await context.Customers.SingleAsync(c => c.Name == "Willow");    var address = customer.ContactInfo.ShippingAddress;    var mobile = customer.ContactInfo.Phones.MobilePhone;    Console.WriteLine($"{customer.Id}: {customer.Name}");    Console.WriteLine($"    Shipping to: {address.City}, {address.Country} (+{mobile.CountryCode} {mobile.Number})");}

Running this code results in the following output:

336d4936-d048-469e-84c8-d5ebc17754ff: Willow    Shipping to: Walpole St Peter, UK (+1 (555) 2345-678)

Notice that the query pulled back the entire document, not just theCustomer object, so we are able to access and print out the customer’s contact info without going back to the database.

Other LINQ operators can be used to perform filtering, etc. For example, to bring back all customers where theSpecies isDog:

var customers = await context.Customers    .Where(e => e.Species == Species.Dog)    .ToListAsync();

Updating a document

By default,EF tracks the object graphs returned from queries. Then, whenSaveChanges orSaveChangesAsync is called, EF detects any changes that have been made to the document and sends an update to MongoDB to update that document. For example:

using (var context = new CustomersContext(mongoClient)){    var baxter = (await context.Customers.FindAsync(baxterId))!;    baxter.ContactInfo.ShippingAddress = new()    {        Line1 = "Via Giovanni Miani",        City = "Rome",        Country = "IT",        PostalCode = "00154"    };    await context.SaveChangesAsync();}

In this case, we’re usingFindAsync to query a customer by primary key–a LINQ query would work just as well. After that, we change the shipping address to Rome, and callSaveChangesAsync. EF detects that only the shipping address for a single document has been changed, and so sends a partial update to patch the updated address into the document stored in the MongoDB database.

Going forward

So far, the MongoDB provider for EF Core is only in its first preview. Full CRUD (creating, reading, updating, and deleting documents) is supported by this preview, but there are some limitations. See thereadme on GitHub for more information, and for places to ask questions and file bugs.

Learn more

To learn more about EF Core and MongoDB:

Summary

We usedTestcontainers to try out thefirst preview release of the MongoDB provider for EF Core. Testcontainers allowed us to test MongoDB with very minimal setup, and we were able to create, query, and update documents in the MongoDB database using EF Core.

3 comments

Discussion is closed.Login to edit/delete existing comments.

Stay informed

Get notified when new posts are published.
Follow this blog
facebooklinkedinyoutubetwitchStackoverflow