From 3e059c42d62fffd04f05a33a7fff182bc080d2f5 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Sun, 21 Aug 2022 14:44:40 +0100 Subject: [PATCH] Initial JSON columns sample --- .../core/what-is-new/ef-core-7.0/whatsnew.md | 409 ++++++++++++++++++ .../Multitenancy/Common/Contact.cs | 6 +- .../NewInEFCore7/BlogsContext.cs | 337 +++++++++++++-- .../NewInEFCore7/JsonColumnsSample.cs | 140 ++++++ .../NewInEFCore7/NewInEFCore7.csproj | 1 + .../Miscellaneous/NewInEFCore7/Program.cs | 31 +- 6 files changed, 880 insertions(+), 44 deletions(-) create mode 100644 samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs diff --git a/entity-framework/core/what-is-new/ef-core-7.0/whatsnew.md b/entity-framework/core/what-is-new/ef-core-7.0/whatsnew.md index 40f3fc4922..ca4679c375 100644 --- a/entity-framework/core/what-is-new/ef-core-7.0/whatsnew.md +++ b/entity-framework/core/what-is-new/ef-core-7.0/whatsnew.md @@ -19,6 +19,415 @@ EF7 is also available as [daily builds](https://github.com/dotnet/efcore/blob/ma EF7 targets .NET 6, and so can be used with either [.NET 6 (LTS)](https://dotnet.microsoft.com/download/dotnet/6.0) or [.NET 7](https://dotnet.microsoft.com/download/dotnet/7.0). +## JSON Columns + +Most relational databases support columns that contain JSON documents. The JSON in these columns can be drilled into with queries. This allows, for example, filtering and sorting by the elements of the documents, as well as projection of elements out of the documents into results. JSON columns allow relational databases to take on some of the characteristics of document databases, creating a useful hybrid between the two. + +EF7 contains provider-agnostic support for JSON columns, with an implementation for SQL Server. This support allows mapping of aggregates built from .NET types to JSON documents. Normal LINQ queries can be used on the aggregates, and these will be translated to the appropriate query constructs needed to drill into the JSON. EF7 also supports updating and saving changes to the JSON documents. + +> [!NOTE] +> SQLite support for JSON is [planned for post EF7](https://github.com/dotnet/efcore/issues/28816). The PostgreSQL and Pomelo MySQL providers already contain some support for JSON columns. We will be working with the authors of those providers to align JSON support across all providers. + +### Mapping to JSON columns + +In EF Core, aggregate types are defined using `OwnsOne` and `OwnsMany`. For example, consider an aggregate type to store contact information: + + +[!code-csharp[ContactDetailsAggregate](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=ContactDetailsAggregate)] + +This can then be used in an "owner" entity type, for example, to store the contact details of an author: + +```csharp +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } + public ContactDetails Contact { get; set; } +} +``` + +The aggregate type is configured in `OnModelCreating` using `OwnsOne`: + + +[!code-csharp[TableSharingAggregate](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=TableSharingAggregate)] + +By default, relational database providers map aggregate types like this to the same table as the owning entity type. That is, each property of the `ContactDetails` and `Address` classes are mapped to a column in the `Authors` table. + +Some saved authors with contact details will look like this: + +**Authors** + +| Id | Name | Contact\_Address\_Street | Contact\_Address\_City | Contact\_Address\_Postcode | Contact\_Address\_Country | Contact\_Phone | +|:----|:-----------------|:-------------------------|:-----------------------|:---------------------------|:--------------------------|:---------------| +| 1 | Maddy Montaquila | 1 Main St | Camberwick Green | CW1 5ZH | UK | 01632 12345 | +| 2 | Jeremy Likness | 2 Main St | Chigley | CW1 5ZH | UK | 01632 12346 | +| 3 | Daniel Roth | 3 Main St | Camberwick Green | CW1 5ZH | UK | 01632 12347 | +| 4 | Arthur Vickers | 15a Main St | Chigley | CW1 5ZH | United Kingdom | 01632 22345 | +| 5 | Brice Lambson | 4 Main St | Chigley | CW1 5ZH | UK | 01632 12349 | + +If desired, each entity type making up the aggregate can be mapped to its own table instead: + + +[!code-csharp[TableMappedAggregate](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=TableMappedAggregate)] + +The same data is then stored across three tables: + +**Authors** + +| Id | Name | +|:----|:-----------------| +| 1 | Maddy Montaquila | +| 2 | Jeremy Likness | +| 3 | Daniel Roth | +| 4 | Arthur Vickers | +| 5 | Brice Lambson | + +**Contacts** + +| AuthorId | Phone | +|:---------|:------------| +| 1 | 01632 12345 | +| 2 | 01632 12346 | +| 3 | 01632 12347 | +| 4 | 01632 22345 | +| 5 | 01632 12349 | + +**Addresses** + +| ContactDetailsAuthorId | Street | City | Postcode | Country | +|:-----------------------|:------------|:-----------------|:---------|:---------------| +| 1 | 1 Main St | Camberwick Green | CW1 5ZH | UK | +| 2 | 2 Main St | Chigley | CW1 5ZH | UK | +| 3 | 3 Main St | Camberwick Green | CW1 5ZH | UK | +| 4 | 15a Main St | Chigley | CW1 5ZH | United Kingdom | +| 5 | 4 Main St | Chigley | CW1 5ZH | UK | + +Now, for the interesting part. In EF7, the `ContactDetails` aggregate type can be mapped to a JSON column. This requires just one call to `ToJson()` when configuring the aggregate type: + + +[!code-csharp[JsonColumnAggregate](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=JsonColumnAggregate)] + +The `Authors` table will now contain a JSON column for `ContactDetails` populated with a JSON document for each author: + +**Authors** + +| Id | Name | Contact | +|:----|:-----------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 1 | Maddy Montaquila | {
  "Phone":"01632 12345",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"1 Main St"
  }
} | +| 2 | Jeremy Likness | {
  "Phone":"01632 12346",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"2 Main St"
  }
} | +| 3 | Daniel Roth | {
  "Phone":"01632 12347",
  "Address": {
    "City":"Camberwick Green",
    "Country":"UK",
    "Postcode":"CW1 5ZH",
    "Street":"3 Main St"
  }
} | +| 4 | Arthur Vickers | {
  "Phone":"01632 12348",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"15a Main St"
  }
} | +| 5 | Brice Lambson | {
  "Phone":"01632 12349",
  "Address": {
    "City":"Chigley",
    "Country":"UK",
    "Postcode":"CH1 5ZH",
    "Street":"4 Main St"
  }
} | + + +> [!TIP] +> This use of aggregates is very similar to the way JSON documents are mapped when using the EF Core provider for Azure Cosmos DB. JSON columns bring the capabilities of using EF Core against document databases to documents embedded in a relational database. + +The JSON documents shown above are very simple, but this mapping capability can also be used with more complex document structures. For example, consider an aggregate type used to represent metadata about a post: + + +[!code-csharp[PostMetadataAggregate](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=PostMetadataAggregate)] + +This aggregate type contains several nested types and collections. Calls to `OwnsOne` and `OwnsMany` are used to map this aggregate type: + + +[!code-csharp[PostMetadataConfig](../../../../samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs?name=PostMetadataConfig)] + +With this mapping, EF7 can create and query into a complex JSON document like this: + +```json +{ + "Views": 5085, + "TopGeographies": [ + { + "Browsers": "Firefox, Netscape", + "Count": 924, + "Latitude": 110.793, + "Longitude": 39.2431 + }, + { + "Browsers": "Firefox, Netscape", + "Count": 885, + "Latitude": 133.793, + "Longitude": 45.2431 + } + ], + "TopSearches": [ + { + "Count": 9359, + "Term": "Search #1" + } + ], + "Updates": [ + { + "PostedFrom": "127.0.0.1", + "UpdatedBy": "Admin", + "UpdatedOn": "1996-02-17T19:24:29.5429092Z", + "Commits": [] + }, + { + "PostedFrom": "127.0.0.1", + "UpdatedBy": "Admin", + "UpdatedOn": "2019-11-24T19:24:29.5429093Z", + "Commits": [ + { + "Comment": "Commit #1", + "CommittedOn": "2022-08-21T00:00:00+01:00" + } + ] + }, + { + "PostedFrom": "127.0.0.1", + "UpdatedBy": "Admin", + "UpdatedOn": "1997-05-28T19:24:29.5429097Z", + "Commits": [ + { + "Comment": "Commit #1", + "CommittedOn": "2022-08-21T00:00:00+01:00" + }, + { + "Comment": "Commit #2", + "CommittedOn": "2022-08-21T00:00:00+01:00" + } + ] + } + ] +} +``` + +> [!NOTE] +> Mapping spatial types directly to JSON is not yet supported. The document above uses `double` values as a workaround. Vote for [Support spatial types in JSON columns](https://github.com/dotnet/efcore/issues/28811) if this is something you are interested in. + +> [!NOTE] +> Mapping collections of primitive types to JSON is not yet supported. The document above uses a value converter to transform the collection into a comma-separated string. Vote for [Json: add support for collection of primitive types](https://github.com/dotnet/efcore/issues/28688) if this is something you are interested in. + +### Queries into JSON columns + +Queries into JSON columns work just the same as querying into any other aggregate type in EF Core. That is, just use LINQ! Here are some examples. + +A query for all authors that live in Chigley: + + +[!code-csharp[AuthorsInChigley](../../../../samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs?name=AuthorsInChigley)] + +This query generates the following SQL when using SQL Server: + +```sql +SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$') +FROM [Authors] AS [a] +WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' +``` + +Notice the use of `JSON_VALUE` to get the `City` from the `Address` inside the JSON document. + +`Select` can be used to extract and project elements from the JSON document: + + +[!code-csharp[PostcodesInChigley](../../../../samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs?name=PostcodesInChigley)] + +This query generates the following SQL: + +```sql +SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max)) +FROM [Authors] AS [a] +WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' +``` + +Here's an example that does a bit more in the filter and projection, and also orders by the phone number in the JSON document: + + +[!code-csharp[OrderedAddresses](../../../../samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs?name=OrderedAddresses)] + +This query generates the following SQL: + +```sql +SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')' +FROM [Authors] AS [a] +WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%') +ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) +``` + +And when the JSON document contains collections, then these can be projected out in the results: + + +[!code-csharp[PostsWithViews](../../../../samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs?name=PostsWithViews)] + +This query generates the following SQL: + +```sql +SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates') +FROM [Posts] AS [p] +LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id] +WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000 +``` + +> [!NOTE] +> More complex queries involving JSON collections require `jsonpath` support. Vote for [Support jsonpath querying](https://github.com/dotnet/efcore/issues/28616) if this is something you are interested in. + ## ExecuteUpdate and ExecuteDelete (Bulk updates) By default, EF Core [tracks changes to entities](xref:core/change-tracking/index), and then [sends updates to the database](xref:core/saving/index) when one of the `SaveChanges` methods is called. Changes are only sent for properties and relationships that have actually changed. Also, the tracked entities remain in sync with the changes sent to the database. This mechanism is an efficient and convenient way to send general-purpose inserts, updates, and deletes to the database. These changes are also batched to reduce the number of database round-trips. diff --git a/samples/core/Miscellaneous/Multitenancy/Common/Contact.cs b/samples/core/Miscellaneous/Multitenancy/Common/Contact.cs index de3932c24c..7262834591 100644 --- a/samples/core/Miscellaneous/Multitenancy/Common/Contact.cs +++ b/samples/core/Miscellaneous/Multitenancy/Common/Contact.cs @@ -2,18 +2,18 @@ public class Contact { public Guid Id { get; set; } - public string Name {get; set;} + public string Name { get; set; } = null!; public bool IsUnicorn {get; set; } public static Contact[] GeneratedContacts => - new [] + new [] { new Contact { Name = "Magic Unicorns", IsUnicorn = true }, - new Contact + new Contact { Name = "Unicorns Running", IsUnicorn = true diff --git a/samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs b/samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs index fbdcb7b6a5..c6e03f4bf7 100644 --- a/samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs +++ b/samples/core/Miscellaneous/NewInEFCore7/BlogsContext.cs @@ -1,3 +1,7 @@ +using System.Net; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; + namespace NewInEfCore7; #region BlogsModel @@ -29,6 +33,7 @@ public Post(string title, string content, DateTime publishedOn) public Blog Blog { get; set; } = null!; public List Tags { get; } = new(); public Author? Author { get; set; } + public PostMetadata? Metadata { get; set; } } public class FeaturedPost : Post @@ -66,11 +71,13 @@ public Author(string name) public ContactDetails Contact { get; set; } = null!; public List Posts { get; } = new(); } +#endregion +#region ContactDetailsAggregate public class ContactDetails { public Address Address { get; init; } = null!; - public string? Phone { get; init; } + public string? Phone { get; set; } } public class Address @@ -83,10 +90,78 @@ public Address(string street, string city, string postcode, string country) Country = country; } - public string Street { get; init; } - public string City { get; init; } - public string Postcode { get; init; } - public string Country { get; init; } + public string Street { get; set; } + public string City { get; set; } + public string Postcode { get; set; } + public string Country { get; set; } +} +#endregion + +#region PostMetadataAggregate +public class PostMetadata +{ + public PostMetadata(int views) + { + Views = views; + } + + public int Views { get; set; } + public List TopSearches { get; } = new(); + public List TopGeographies { get; } = new(); + public List Updates { get; } = new(); +} + +public class SearchTerm +{ + public SearchTerm(string term, int count) + { + Term = term; + Count = count; + } + + public string Term { get; private set; } + public int Count { get; private set; } +} + +public class Visits +{ + public Visits(double latitude, double longitude, int count) + { + Latitude = latitude; + Longitude = longitude; + Count = count; + } + + public double Latitude { get; private set; } + public double Longitude { get; private set; } + public int Count { get; private set; } + public List? Browsers { get; set; } +} + +public class PostUpdate +{ + public PostUpdate(IPAddress postedFrom, DateTime updatedOn) + { + PostedFrom = postedFrom; + UpdatedOn = updatedOn; + } + + public IPAddress PostedFrom { get; private set; } + public string? UpdatedBy { get; init; } + public DateTime UpdatedOn { get; private set; } + public List Commits { get; } = new(); +} + +public class Commit +{ + public Commit(DateTime committedOn, string comment) + { + CommittedOn = committedOn; + Comment = comment; + } + + public DateTime CommittedOn { get; private set; } + public string Comment { get; set; } } #endregion @@ -109,7 +184,8 @@ protected BlogsContext(bool useSqlite) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => (UseSqlite ? optionsBuilder.UseSqlite(@$"DataSource={GetType().Name}") - : optionsBuilder.UseSqlServer(@$"Server=(localdb)\mssqllocaldb;Database={GetType().Name}")) + : optionsBuilder.UseSqlServer(@$"Server=(localdb)\mssqllocaldb;Database={GetType().Name}", + sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseNetTopologySuite())) .EnableSensitiveDataLogging() .LogTo( s => @@ -123,10 +199,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(); - - // https://github.com/dotnet/efcore/issues/28671 - // modelBuilder.Entity().OwnsOne(e => e.Contact).OwnsOne(e => e.Address); - modelBuilder.Entity().Ignore(e => e.Contact); } public async Task Seed() @@ -151,7 +223,7 @@ public async Task Seed() }; var jeremy = new Author("Jeremy Likness") { - Contact = new() { Address = new("2 Main St", "Camberwick Green", "CW1 5ZH", "UK"), Phone = "01632 12346" } + Contact = new() { Address = new("2 Main St", "Chigley", "CW1 5ZH", "UK"), Phone = "01632 12346" } }; var dan = new Author("Daniel Roth") { @@ -159,11 +231,11 @@ public async Task Seed() }; var arthur = new Author("Arthur Vickers") { - Contact = new() { Address = new("15a Main St", "Camberwick Green", "CW1 5ZH", "UK"), Phone = "01632 12348" } + Contact = new() { Address = new("15a Main St", "Chigley", "CW1 5ZH", "UK"), Phone = "01632 12348" } }; var brice = new Author("Brice Lambson") { - Contact = new() { Address = new("4 Main St", "Camberwick Green", "CW1 5ZH", "UK"), Phone = "01632 12349" } + Contact = new() { Address = new("4 Main St", "Chigley", "CW1 5ZH", "UK"), Phone = "01632 12349" } }; var blogs = new List @@ -175,18 +247,24 @@ public async Task Seed() new Post( "Productivity comes to .NET MAUI in Visual Studio 2022", "Visual Studio 2022 17.3 is now available and...", - new DateTime(2022, 8, 9)) { Tags = { tagDotNetMaui, tagDotNet }, Author = maddy, }, + new DateTime(2022, 8, 9)) { Tags = { tagDotNetMaui, tagDotNet }, Author = maddy, Metadata = BuildPostMetadata() }, new Post( "Announcing .NET 7 Preview 7", ".NET 7 Preview 7 is now available with improvements to System.LINQ, Unix...", - new DateTime(2022, 8, 9)) { Tags = { tagDotNet }, Author = jeremy, }, + new DateTime(2022, 8, 9)) { Tags = { tagDotNet }, Author = jeremy, Metadata = BuildPostMetadata() }, new Post( "ASP.NET Core updates in .NET 7 Preview 7", ".NET 7 Preview 7 is now available! Check out what's new in...", - new DateTime(2022, 8, 9)) { Tags = { tagDotNet, tagAspDotNet, tagAspDotNetCore }, Author = dan, }, + new DateTime(2022, 8, 9)) + { + Tags = { tagDotNet, tagAspDotNet, tagAspDotNetCore }, Author = dan, Metadata = BuildPostMetadata() + }, new FeaturedPost( "Announcing Entity Framework 7 Preview 7: Interceptors!", "Announcing EF7 Preview 7 with new and improved interceptors, and...", new DateTime(2022, 8, 9), - "Loads of runnable code!") { Tags = { tagEntityFramework, tagDotNet, tagDotNetCore }, Author = arthur, } + "Loads of runnable code!") + { + Tags = { tagEntityFramework, tagDotNet, tagDotNetCore }, Author = arthur, Metadata = BuildPostMetadata() + } }, }, new("1unicorn2") @@ -196,18 +274,18 @@ public async Task Seed() new Post( "Hacking my Sixth Form College network in 1991", "Back in 1991 I was a student at Franklin Sixth Form College...", - new DateTime(2020, 4, 10)) { Tags = { tagHacking }, Author = arthur, }, + new DateTime(2020, 4, 10)) { Tags = { tagHacking }, Author = arthur, Metadata = BuildPostMetadata() }, new FeaturedPost( "All your versions are belong to us", "Totally made up conversations about choosing Entity Framework version numbers...", new DateTime(2020, 3, 26), - "Way funny!") { Tags = { tagEntityFramework }, Author = arthur, }, + "Way funny!") { Tags = { tagEntityFramework }, Author = arthur, Metadata = BuildPostMetadata() }, new Post( "Moving to Linux", "A few weeks ago, I decided to move from Windows to Linux as...", - new DateTime(2020, 3, 7)) { Tags = { tagLinux }, Author = arthur, }, + new DateTime(2020, 3, 7)) { Tags = { tagLinux }, Author = arthur, Metadata = BuildPostMetadata() }, new Post( "Welcome to One Unicorn 2.0!", "I created my first blog back in 2011..", - new DateTime(2020, 2, 29)) { Tags = { tagEntityFramework }, Author = arthur, } + new DateTime(2020, 2, 29)) { Tags = { tagEntityFramework }, Author = arthur, Metadata = BuildPostMetadata() } } }, new("Brice's Blog") @@ -216,17 +294,23 @@ public async Task Seed() { new FeaturedPost( "SQLite in Visual Studio 2022", "A couple of years ago, I was thinking of ways...", - new DateTime(2022, 7, 26), "Love for VS!") { Tags = { tagSqlite, tagVisualStudio }, Author = brice, }, + new DateTime(2022, 7, 26), "Love for VS!") + { + Tags = { tagSqlite, tagVisualStudio }, Author = brice, Metadata = BuildPostMetadata() + }, new Post( "On .NET - Entity Framework Migrations Explained", "This week, @JamesMontemagno invited me onto the On .NET show...", - new DateTime(2022, 5, 4)) { Tags = { tagEntityFramework, tagDotNet }, Author = brice, }, + new DateTime(2022, 5, 4)) + { + Tags = { tagEntityFramework, tagDotNet }, Author = brice, Metadata = BuildPostMetadata() + }, new Post( "Dear DBA: A silly idea", "We have fun on the Entity Framework team...", - new DateTime(2022, 3, 31)) { Tags = { tagEntityFramework }, Author = brice, }, + new DateTime(2022, 3, 31)) { Tags = { tagEntityFramework }, Author = brice, Metadata = BuildPostMetadata() }, new Post( "Microsoft.Data.Sqlite 6", "It’s that time of year again. Microsoft.Data.Sqlite version...", - new DateTime(2021, 11, 8)) { Tags = { tagSqlite, tagDotNet }, Author = brice, } + new DateTime(2021, 11, 8)) { Tags = { tagSqlite, tagDotNet }, Author = brice, Metadata = BuildPostMetadata() } } }, new("Developer for Life") @@ -235,7 +319,10 @@ public async Task Seed() { new Post( "GraphQL for .NET Developers", "A comprehensive overview of GraphQL as...", - new DateTime(2021, 7, 1)) { Tags = { tagDotNet, tagGraphQl, tagAspDotNetCore }, Author = jeremy, }, + new DateTime(2021, 7, 1)) + { + Tags = { tagDotNet, tagGraphQl, tagAspDotNetCore }, Author = jeremy, Metadata = BuildPostMetadata() + }, new FeaturedPost( "Azure Cosmos DB With EF Core on Blazor Server", "Learn how to build Azure Cosmos DB apps using Entity Framework Core...", @@ -251,23 +338,69 @@ public async Task Seed() tagBlazor }, Author = jeremy, + Metadata = BuildPostMetadata() }, new Post( "Multi-tenancy with EF Core in Blazor Server Apps", "Learn several ways to implement multi-tenant databases in Blazor Server apps...", new DateTime(2021, 4, 29)) { - Tags = { tagDotNet, tagEntityFramework, tagAspDotNetCore, tagBlazor }, Author = jeremy, + Tags = { tagDotNet, tagEntityFramework, tagAspDotNetCore, tagBlazor }, + Author = jeremy, + Metadata = BuildPostMetadata() }, new Post( "An Easier Blazor Debounce", "Where I propose a simple method to debounce input without...", - new DateTime(2021, 4, 12)) { Tags = { tagDotNet, tagAspDotNetCore, tagBlazor }, Author = jeremy, } + new DateTime(2021, 4, 12)) + { + Tags = { tagDotNet, tagAspDotNetCore, tagBlazor }, Author = jeremy, Metadata = BuildPostMetadata() + } } } }; await AddRangeAsync(blogs); await SaveChangesAsync(); + + PostMetadata BuildPostMetadata() + { + var random = new Random(Guid.NewGuid().GetHashCode()); + + var metadata = new PostMetadata(random.Next(10000)); + + for (var i = 0; i < random.Next(5); i++) + { + var update = new PostUpdate(IPAddress.Loopback, DateTime.UtcNow - TimeSpan.FromDays(random.Next(1, 10000))) + { + UpdatedBy = "Admin" + }; + + for (var j = 0; j < random.Next(3); j++) + { + update.Commits.Add(new(DateTime.Today, $"Commit #{j + 1}")); + } + + metadata.Updates.Add(update); + } + + for (var i = 0; i < random.Next(5); i++) + { + metadata.TopSearches.Add(new($"Search #{i + 1}", 10000 - random.Next(i * 1000, i * 1000 + 900))); + } + + for (var i = 0; i < random.Next(5); i++) + { + metadata.TopGeographies.Add( + new( + // Issue https://github.com/dotnet/efcore/issues/28811 (Support spatial types in JSON columns) + // new Point(115.7930 + 20 - random.Next(40), 37.2431 + 10 - random.Next(20)) { SRID = 4326 }, + 115.7930 + 20 - random.Next(40), + 37.2431 + 10 - random.Next(20), + 1000 - random.Next(i * 100, i * 100 + 90)) { Browsers = new() { "Firefox", "Netscape" } }); + } + + return metadata; + } } } @@ -285,6 +418,17 @@ public TphBlogsContext() { } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Ignore(e => e.Metadata); + + // https://github.com/dotnet/efcore/issues/28671 + // modelBuilder.Entity().OwnsOne(e => e.Contact).OwnsOne(e => e.Address); + modelBuilder.Entity().Ignore(e => e.Contact); + + base.OnModelCreating(modelBuilder); + } + public override MappingStrategy MappingStrategy => MappingStrategy.Tph; } @@ -296,6 +440,17 @@ public TphSqliteBlogsContext() } public override MappingStrategy MappingStrategy => MappingStrategy.Tph; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Ignore(e => e.Metadata); + + // https://github.com/dotnet/efcore/issues/28671 + // modelBuilder.Entity().OwnsOne(e => e.Contact).OwnsOne(e => e.Address); + modelBuilder.Entity().Ignore(e => e.Contact); + + base.OnModelCreating(modelBuilder); + } } public class TptBlogsContext : BlogsContext @@ -310,6 +465,11 @@ public TptBlogsContext() protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().ToTable("FeaturedPosts"); + modelBuilder.Entity().Ignore(e => e.Metadata); + + // https://github.com/dotnet/efcore/issues/28671 + // modelBuilder.Entity().OwnsOne(e => e.Contact).OwnsOne(e => e.Address); + modelBuilder.Entity().Ignore(e => e.Contact); base.OnModelCreating(modelBuilder); } @@ -328,7 +488,128 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().UseTpcMappingStrategy(); modelBuilder.Entity().ToTable("FeaturedPosts"); + modelBuilder.Entity().Ignore(e => e.Metadata); + + // https://github.com/dotnet/efcore/issues/28671 + // modelBuilder.Entity().OwnsOne(e => e.Contact).OwnsOne(e => e.Address); + modelBuilder.Entity().Ignore(e => e.Contact); base.OnModelCreating(modelBuilder); } } + +public abstract class JsonBlogsContextBase : BlogsContext +{ + protected JsonBlogsContextBase(bool useSqlite) + : base(useSqlite) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne( + author => author.Contact, ownedNavigationBuilder => + { + ownedNavigationBuilder.ToJson(); + ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address); + }); + + #region PostMetadataConfig + modelBuilder.Entity().OwnsOne( + post => post.Metadata, ownedNavigationBuilder => + { + ownedNavigationBuilder.ToJson(); + ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches); + ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies); + ownedNavigationBuilder.OwnsMany( + metadata => metadata.Updates, + ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits)); + }); + #endregion + + base.OnModelCreating(modelBuilder); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // Issue https://github.com/dotnet/efcore/issues/28688 (Json: add support for collection of primitive types) + configurationBuilder.Properties>().HaveConversion(); + + base.ConfigureConventions(configurationBuilder); + } + + private class StringListConverter : ValueConverter, string> + { + public StringListConverter() + : base(v => string.Join(", ", v!), v => v.Split(',', StringSplitOptions.TrimEntries).ToList()) + { + } + } + + public override MappingStrategy MappingStrategy => MappingStrategy.Tph; +} + +public class JsonBlogsContext : JsonBlogsContextBase +{ + public JsonBlogsContext() + : base(useSqlite: false) + { + } +} + +public class JsonBlogsContextSqlite : JsonBlogsContextBase +{ + public JsonBlogsContextSqlite() + : base(useSqlite: true) + { + } +} + +// Used only for code snippets: + +public abstract class TableSharingAggregateContext : TphBlogsContext +{ + #region TableSharingAggregate + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne( + author => author.Contact, ownedNavigationBuilder => + { + ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address); + }); + } + #endregion +} + +public abstract class TableMappedAggregateContext : TphBlogsContext +{ + #region TableMappedAggregate + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne( + author => author.Contact, ownedNavigationBuilder => + { + ownedNavigationBuilder.ToTable("Contacts"); + ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder => + { + ownedOwnedNavigationBuilder.ToTable("Addresses"); + }); + }); + } + #endregion +} + +public abstract class JsonColumnAggregateContext : TphBlogsContext +{ + #region JsonColumnAggregate + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne( + author => author.Contact, ownedNavigationBuilder => + { + ownedNavigationBuilder.ToJson(); + ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address); + }); + } + #endregion +} diff --git a/samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs b/samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs new file mode 100644 index 0000000000..8eaeab5add --- /dev/null +++ b/samples/core/Miscellaneous/NewInEFCore7/JsonColumnsSample.cs @@ -0,0 +1,140 @@ +using System.Net; + +namespace NewInEfCore7; + +public static class JsonColumnsSample +{ + public static Task Json_columns_with_TPH() + { + Console.WriteLine($">>>> Sample: {nameof(Json_columns_with_TPH)}"); + Console.WriteLine(); + + return JsonColumnsTest(); + } + + public static Task Json_columns_with_TPH_on_SQLite() + { + Console.WriteLine($">>>> Sample: {nameof(Json_columns_with_TPH_on_SQLite)}"); + Console.WriteLine(); + + return JsonColumnsTest(); + } + + private static async Task JsonColumnsTest() + where TContext : BlogsContext, new() + { + await using var context = new TContext(); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + await context.Seed(); + + context.LoggingEnabled = true; + context.ChangeTracker.Clear(); + + #region AuthorsInChigley + var authorsInChigley = await context.Authors + .Where(author => author.Contact.Address.City == "Chigley") + .ToListAsync(); + #endregion + + Console.WriteLine(); + foreach (var author in authorsInChigley) + { + Console.WriteLine($"{author.Name} lives at '{author.Contact.Address.Street}' in Chigley."); + } + + Console.WriteLine(); + + #region PostcodesInChigley + var postcodesInChigley = await context.Authors + .Where(author => author.Contact.Address.City == "Chigley") + .Select(author => author.Contact.Address.Postcode) + .ToListAsync(); + #endregion + + Console.WriteLine(); + Console.WriteLine($"Postcodes in Chigley are '{string.Join("', '", postcodesInChigley)}'"); + Console.WriteLine(); + + #region OrderedAddresses + var orderedAddresses = await context.Authors + .Where( + author => (author.Contact.Address.City == "Chigley" + && author.Contact.Phone != null) + || author.Name.StartsWith("D")) + .OrderBy(author => author.Contact.Phone) + .Select( + author => author.Name + " (" + author.Contact.Address.Street + + ", " + author.Contact.Address.City + + " " + author.Contact.Address.Postcode + ")") + .ToListAsync(); + #endregion + + Console.WriteLine(); + foreach (var address in orderedAddresses) + { + Console.WriteLine(address); + } + + Console.WriteLine(); + + // Since query below cannot use Include + // Issue: https://github.com/dotnet/efcore/issues/28808 + await context.Posts.LoadAsync(); + + var authorsInChigleyWithPosts = await context.Authors + .Where( + author => author.Contact.Address.City == "Chigley" + && author.Posts.Count > 1) + //.Include(author => author.Posts) + .ToListAsync(); + + Console.WriteLine(); + foreach (var author in authorsInChigleyWithPosts) + { + Console.WriteLine($"{author.Name} has {author.Posts.Count} posts"); + } + + Console.WriteLine(); + + #region PostsWithViews + var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000) + .AsNoTracking() + .Select( + post => new + { + post.Author!.Name, + post.Metadata!.Views, + Searches = post.Metadata.TopSearches, + Commits = post.Metadata.Updates + }) + .ToListAsync(); + #endregion + + Console.WriteLine(); + foreach (var postWithViews in postsWithViews) + { + Console.WriteLine($"Post by {postWithViews.Name} with {postWithViews.Views} views had {postWithViews.Commits.Count} commits with {postWithViews.Searches.Sum(term => term.Count)} searches"); + } + + Console.WriteLine(); + + context.ChangeTracker.Clear(); + + var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur")); + + arthur.Contact.Phone = "01632 22345"; + arthur.Contact.Address.Country = "United Kingdom"; + + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var post = await context.Posts.SingleAsync(post => post.Title.StartsWith("Hacking")); + + post.Metadata!.Updates.Add(new PostUpdate(IPAddress.Broadcast, DateTime.UtcNow) { UpdatedBy = "User" }); + post.Metadata!.TopGeographies.Clear(); + + await context.SaveChangesAsync(); + } +} diff --git a/samples/core/Miscellaneous/NewInEFCore7/NewInEFCore7.csproj b/samples/core/Miscellaneous/NewInEFCore7/NewInEFCore7.csproj index 1df0ff3b62..88e1a0862a 100644 --- a/samples/core/Miscellaneous/NewInEFCore7/NewInEFCore7.csproj +++ b/samples/core/Miscellaneous/NewInEFCore7/NewInEFCore7.csproj @@ -12,6 +12,7 @@ + diff --git a/samples/core/Miscellaneous/NewInEFCore7/Program.cs b/samples/core/Miscellaneous/NewInEFCore7/Program.cs index 3c9721dc62..432f35230a 100644 --- a/samples/core/Miscellaneous/NewInEFCore7/Program.cs +++ b/samples/core/Miscellaneous/NewInEFCore7/Program.cs @@ -4,20 +4,25 @@ public class Program { public static async Task Main() { - await TpcInheritanceSample.Inheritance_with_TPH(); - await TpcInheritanceSample.Inheritance_with_TPT(); - await TpcInheritanceSample.Inheritance_with_TPC(); - await TpcInheritanceSample.Inheritance_with_TPC_using_HiLo(); - await TpcInheritanceSample.Inheritance_with_TPC_using_Identity(); + // await TpcInheritanceSample.Inheritance_with_TPH(); + // await TpcInheritanceSample.Inheritance_with_TPT(); + // await TpcInheritanceSample.Inheritance_with_TPC(); + // await TpcInheritanceSample.Inheritance_with_TPC_using_HiLo(); + // await TpcInheritanceSample.Inheritance_with_TPC_using_Identity(); + // + // await ExecuteDeleteSample.ExecuteDelete(); + // await ExecuteDeleteSample.ExecuteDeleteTpt(); + // await ExecuteDeleteSample.ExecuteDeleteTpc(); + // await ExecuteDeleteSample.ExecuteDeleteSqlite(); + // + // await ExecuteUpdateSample.ExecuteUpdate(); + // await ExecuteUpdateSample.ExecuteUpdateTpt(); + // await ExecuteUpdateSample.ExecuteUpdateTpc(); + // await ExecuteUpdateSample.ExecuteUpdateSqlite(); - await ExecuteDeleteSample.ExecuteDelete(); - await ExecuteDeleteSample.ExecuteDeleteTpt(); - await ExecuteDeleteSample.ExecuteDeleteTpc(); - await ExecuteDeleteSample.ExecuteDeleteSqlite(); + await JsonColumnsSample.Json_columns_with_TPH(); - await ExecuteUpdateSample.ExecuteUpdate(); - await ExecuteUpdateSample.ExecuteUpdateTpt(); - await ExecuteUpdateSample.ExecuteUpdateTpc(); - await ExecuteUpdateSample.ExecuteUpdateSqlite(); + // Issue https://github.com/dotnet/efcore/issues/28816 (Json: add support for Sqlite provider) + // await JsonColumnsSample.Json_columns_with_TPH_on_SQLite(); } }