diff --git a/.github/agents/docs-generator.agent.md b/.github/agents/docs-generator.agent.md new file mode 100644 index 0000000000..e1170eba9d --- /dev/null +++ b/.github/agents/docs-generator.agent.md @@ -0,0 +1,36 @@ +--- +name: EF Documentation Generator +description: This agent creates documentation PRs in the EF documentation site when new features are implemented in EF Core. +disable-model-invocation: true +--- + +# Document new EF features + +Given an EF issue by the user, this custom agent generates documentation for features introduced in that issue and submits a PR to the EF docs repo (dotnet/EntityFramework.Docs). + +## Target branch + +* The EF repo has automation to automatically add a label indicating in which preview/rc the feature has been completed; the label is applied to the issue (not PR), and has the form `preview-3` or `rc-2` with the number adjusted. +* The docs repo should have a corresponding branch, containing documentation to be published live when that preview/rc is published. +* When the workflow is launched, check the issue, find the preview/rc label, and submit the PR against the corresponding branch in the doc repo (dotnet/EntityFramework.Docs). +* If the label is missing, abort and post a comment to the triggering issue. + +## Writing the documentation + +* Fully read the conversation history of the issue, as well as any linked PRs or relevant issues linked from it, to gain good context on the feature, APIs introduced, etc. +* Add documentation in the appropriate section of the docs, depending on what the feature is. +* Fully document the feature, but keep it brief - do not add edge-case, non-important documentation in the name of exhaustivity that wouldn't be relevant to the majority of users. +* Before the new documentation, add the following note (adjusting for the major version): + +``` +> [!NOTE] +> This feature is being introduced in EF Core 11, which is currently in preview. +``` + +* Find the "what's new" page for the latest major release (typically `core/what-is-new/ef-core-11.0`, adjusting for the version), and add a **brief** section on the feature - just the minimum needed to make the user understand what it's about; include a minimal code sample as well if relevant. At the bottom, add a line such as "For more information on X, see the documentation" linking to the full docs added above, in case the user wants to dive deeper. +* For both the full docs and the what's new documentation, do not simply create a new section; first check to see if there's an existing section that already covers related/similar functionality; if there is, either merge the new content into it or place the new section next to it. +* If the issue adds a function translation, add the appropriate entry (or entries) in the provider's functions page. Do not add functions entries for LINQ operators (e.g. `Contains`). + +## Additional instructions + +* The commit in the resulting PR should have a title of the form "Document X", where X is the name of the feature as it appears in the title of the originating issue. If the title is too long for a git commit, make it shorter. The commit body should be of the form "Document Y", where Y is a link to the originating issue. diff --git a/entity-framework/core/extensions/index.md b/entity-framework/core/extensions/index.md index 0aa159d15f..846940f9ec 100644 --- a/entity-framework/core/extensions/index.md +++ b/entity-framework/core/extensions/index.md @@ -108,6 +108,12 @@ Provides a wrapper around [SQL Server Express LocalDB](/sql/database-engine/conf [GitHub repository](https://github.com/SimonCropp/LocalDb) | [NuGet](https://www.nuget.org/packages/EfLocalDb) +### EfCore.InMemory.Transactions + +Seamless transaction support for EF Core InMemory provider. Eliminates "transactions with isolation level are not supported" errors in tests without changing production code. Provides safe extension methods and NoOpDbContextTransaction for UnitOfWork patterns. For EF Core: 8-10. + +[GitHub repository](https://github.com/ShadyNagy/EfCore.InMemory.Transactions) | [NuGet](https://www.nuget.org/packages/EfCore.InMemory.Transactions) + ### EntityFrameworkCore.Projectables Flexible projection magic for EF Core. Use properties, methods, and extension methods in your query without client evaluation. For EF Core: 3-6, 8. diff --git a/entity-framework/core/learn-more/community-standups.md b/entity-framework/core/learn-more/community-standups.md index e2b0d1dba1..3641d355db 100644 --- a/entity-framework/core/learn-more/community-standups.md +++ b/entity-framework/core/learn-more/community-standups.md @@ -16,6 +16,7 @@ The .NET Data Community Standups are live-streamed monthly (roughly) on Wednesda | Date | Area | Title | |--------------|-----------------------|------------------------------------------------------------------------------------------| +| Feb 12, 2026 | AI | [Adam tells us about Microsoft.Extensions.DataIngestion](#Feb12_2026) | | Nov 20, 2025 | Release | [EF 10 release celebration 🎉](#Nov20_2025) | | Oct 16, 2025 | Migrations | [Jeremy Miller shares his view on migrations and Marten](#Oct16_2025) | | Sep 18, 2025 | ORMs | [Jiri and Nick talk about experience with Dapper and EF Core](#Sep18_2025) | @@ -108,6 +109,25 @@ The .NET Data Community Standups are live-streamed monthly (roughly) on Wednesda | Jun 10, 2020 | EF Core Power Tools | [EF Core Power Tools](#power-tools1) | | May 6, 2020 | Welcome! | [Introducing the EF Core Community Standup](#one) | +## 2026 + + + +### Feb 12: [Adam tells us about Microsoft.Extensions.DataIngestion](https://www.youtube.com/live/pQivzi4n6jM?si=Ms9H0O3Mgw147hhj) + +Microsoft.Extensions.DataIngestion is new addition into Microsoft.Extensions collection and this one touches databases and AI. Exciting. Adam Sitnik, who worked on implementation, tells us what's what and maybe some behind the scenes. + +Featuring: + +- [Adam Sitnik](https://github.com/adamsitnik) (Special guest) +- [Jiri Cincura](https://www.tabsoverspaces.com/) (Host) + +Links: + +- [Introducing Data Ingestion Building Blocks (Preview)](https://devblogs.microsoft.com/dotnet/introducing-data-ingestion-building-blocks-preview/) +- [dotnet/extensions GitHub](https://github.com/dotnet/extensions) +- Template: `Microsoft.Extensions.AI.Templates` + ## 2025 diff --git a/entity-framework/core/logging-events-diagnostics/interceptors.md b/entity-framework/core/logging-events-diagnostics/interceptors.md index cd50d052bf..46bc091a44 100644 --- a/entity-framework/core/logging-events-diagnostics/interceptors.md +++ b/entity-framework/core/logging-events-diagnostics/interceptors.md @@ -2,7 +2,7 @@ title: Interceptors - EF Core description: Interception for database operations and other events author: SamMonoRT -ms.date: 11/15/2021 +ms.date: 02/26/2026 uid: core/logging-events-diagnostics/interceptors ms.custom: sfi-ropc-nochange --- @@ -15,6 +15,20 @@ Interceptors are different from logging and diagnostics in that they allow modif Interceptors are registered per DbContext instance when the context is configured. Use a [diagnostic listener](xref:core/logging-events-diagnostics/diagnostic-listeners) to get the same information but for all DbContext instances in the process. +## Available interceptors + +The following table shows the available interceptor interfaces: + +| Interceptor | Operations intercepted | [Singleton](#singleton-interceptors) | +|:--------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------:| +| | Creating commands
Executing commands
Command failures
Disposing the command's DbDataReader | No | +| | Opening and closing connections
Creating connections
Connection failures | No | +| | Creating transactions
Using existing transactions
Committing transactions
Rolling back transactions
Creating and using savepoints
Transaction failures | No | +| | SavingChanges/SavedChanges
SaveChangesFailed
Optimistic concurrency handling | No | +| | Creating, initializing, and finalizing entity instances from query results | Yes | +| | Modifying the LINQ expression tree before a query is compiled | Yes | +| | Resolving identity conflicts when tracking entities | Yes | + ## Registering interceptors Interceptors are registered using when [configuring a DbContext instance](xref:core/dbcontext-configuration/index). This is commonly done in an override of . For example: @@ -49,6 +63,48 @@ public class TaggedQueryCommandInterceptorContext : BlogsContext Every interceptor instance must implement one or more interface derived from . Each instance should only be registered once even if it implements multiple interception interfaces; EF Core will route events for each interface as appropriate. +### Singleton interceptors + +Some interceptors implement (see table above); these interceptors are registered as singleton services in EF Core's internal service provider, meaning a single instance is shared across all `DbContext` instances that use the same service provider. + +Because singleton interceptors become part of EF Core's internal service configuration, each distinct interceptor instance causes a new internal service provider to be built. Passing a **new instance** of a singleton interceptor each time a `DbContext` is configured--for example, in `AddDbContext`--will eventually trigger a `ManyServiceProvidersCreatedWarning` and degrade performance. + +> [!WARNING] +> Always reuse the same singleton interceptor instance for all `DbContext` instances. Do not create a new instance each time the context is configured. + +For example, the following is **incorrect** because a new interceptor instance is created for each context configuration: + +```csharp +// Don't do this! A new instance each time causes a new internal service provider to be built. +services.AddDbContext( + b => b.UseSqlServer(connectionString) + .AddInterceptors(new MyMaterializationInterceptor())); +``` + +Instead, reuse the same instance: + +```csharp +// Correct: reuse a single interceptor instance +var interceptor = new MyMaterializationInterceptor(); +services.AddDbContext( + b => b.UseSqlServer(connectionString) + .AddInterceptors(interceptor)); +``` + +Or use a static field: + +```csharp +public class CustomerContext : DbContext +{ + private static readonly MyMaterializationInterceptor _interceptor = new(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(_interceptor); +} +``` + +Because these interceptors are singletons, they must be thread-safe. They should generally not hold mutable state. If you need to access scoped services (such as the current `DbContext`), use the or similar properties on the event data passed to each interceptor method. + ## Database interception > [!NOTE] @@ -59,7 +115,7 @@ Low-level database interception is split into the three interfaces shown in the | Interceptor | Database operations intercepted |:-----------------------------------------------------------------------|------------------------------------------------- | | Creating commands
Executing commands
Command failures
Disposing the command's DbDataReader -| | Opening and closing connections
Connection failures +| | Opening and closing connections
Creating connections
Connection failures | | Creating transactions
Using existing transactions
Committing transactions
Rolling back transactions
Creating and using savepoints
Transaction failures The base classes , , and contain no-op implementations for each method in the corresponding interface. Use the base classes to avoid the need to implement unused interception methods. @@ -185,6 +241,91 @@ public class AadAuthenticationInterceptor : DbConnectionInterceptor > [!WARNING] > in some situations the access token may not be cached automatically the Azure Token Provider. Depending on the kind of token requested, you may need to implement your own caching here. +### Example: Lazy initialization of a connection string + +Connection strings are often static assets read from a configuration file. These can easily be passed to `UseSqlServer` or similar when configuring a `DbContext`. However, sometimes the connection string can change for each context instance. For example, each tenant in a multi-tenant system may have a different connection string. + +An can be used to handle dynamic connections and connection strings. This starts with the ability to configure the `DbContext` without any connection string. For example: + +```csharp +services.AddDbContext( + b => b.UseSqlServer()); +``` + +One of the `IDbConnectionInterceptor` methods can then be implemented to configure the connection before it is used. `ConnectionOpeningAsync` is a good choice, since it can perform an async operation to obtain the connection string, find an access token, and so on. For example, imagine a service scoped to the current request that understands the current tenant: + +```csharp +services.AddScoped(); +``` + +> [!WARNING] +> Performing an asynchronous lookup for a connection string, access token, or similar every time it is needed can be very slow. Consider caching these things and only refreshing the cached string or token periodically. For example, access tokens can often be used for a significant period of time before needing to be refreshed. + +This can be injected into each `DbContext` instance using constructor injection: + +```csharp +public class CustomerContext : DbContext +{ + private readonly ITenantConnectionStringFactory _connectionStringFactory; + + public CustomerContext( + DbContextOptions options, + ITenantConnectionStringFactory connectionStringFactory) + : base(options) + { + _connectionStringFactory = connectionStringFactory; + } + + // ... +} +``` + +This service is then used when constructing the interceptor implementation for the context: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors( + new ConnectionStringInitializationInterceptor(_connectionStringFactory)); +``` + +Finally, the interceptor uses this service to obtain the connection string asynchronously and set it the first time that the connection is used: + +```csharp +public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor +{ + private readonly ITenantConnectionStringFactory _connectionStringFactory; + + public ConnectionStringInitializationInterceptor(ITenantConnectionStringFactory connectionStringFactory) + { + _connectionStringFactory = connectionStringFactory; + } + + public override InterceptionResult ConnectionOpening( + DbConnection connection, + ConnectionEventData eventData, + InterceptionResult result) + => throw new NotSupportedException("Synchronous connections not supported."); + + public override async ValueTask ConnectionOpeningAsync( + DbConnection connection, ConnectionEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = new()) + { + if (string.IsNullOrEmpty(connection.ConnectionString)) + { + connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken)); + } + + return result; + } +} +``` + +> [!NOTE] +> The connection string is only obtained the first time that a connection is used. After that, the connection string stored on the `DbConnection` will be used without looking up a new connection string. + +> [!TIP] +> This interceptor overrides the non-async `ConnectionOpening` method to throw since the service to get the connection string must be called from an async code path. + ### Example: Advanced command interception for caching > [!TIP] @@ -388,6 +529,78 @@ Free beer for unicorns Notice from the log output that the application continues to use the cached message until the timeout expires, at which point the database is queried again for any new message. +### Example: Logging SQL Server query statistics + +This example shows two interceptors that work together to send SQL Server query statistics to the application log. To generate the statistics, we need an to do two things. + +First, the interceptor will prefix commands with `SET STATISTICS IO ON`, which tells SQL Server to send statistics to the client after a result set has been consumed: + +```csharp +public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) +{ + command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText; + + return new(result); +} +``` + +Second, the interceptor will implement the `DataReaderClosingAsync` method, which is called after the has finished consuming results, but _before_ it has been closed. When SQL Server is sending statistics, it puts them in a second result on the reader, so at this point the interceptor reads that result by calling `NextResultAsync` which populates statistics onto the connection. + +```csharp +public override async ValueTask DataReaderClosingAsync( + DbCommand command, + DataReaderClosingEventData eventData, + InterceptionResult result) +{ + await eventData.DataReader.NextResultAsync(); + + return result; +} +``` + +The second interceptor is needed to obtain the statistics from the connection and write them out to the application's logger. For this, we'll use an , implementing the `ConnectionCreated` method. `ConnectionCreated` is called immediately after EF Core has created a connection, and so can be used to perform additional configuration of that connection. In this case, the interceptor obtains an `ILogger` and then hooks into the event to log the messages. + +```csharp +public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result) +{ + var logger = eventData.Context!.GetService().CreateLogger("InfoMessageLogger"); + ((SqlConnection)eventData.Connection).InfoMessage += (_, args) => + { + logger.LogInformation(1, args.Message); + }; + return result; +} +``` + +> [!IMPORTANT] +> The `ConnectionCreating` and `ConnectionCreated` methods are only called when EF Core creates a `DbConnection`. They will not be called if the application creates the `DbConnection` and passes it to EF Core. + +### Filtering by command source + +The supplied to diagnostics sources and interceptors contains a property indicating which part of EF was responsible for creating the command. This can be used as a filter in the interceptor. For example, we may want an interceptor that only applies to commands that come from `SaveChanges`: + +```csharp +public class CommandSourceInterceptor : DbCommandInterceptor +{ + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + if (eventData.CommandSource == CommandSource.SaveChanges) + { + Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:"); + Console.WriteLine(); + Console.WriteLine(command.CommandText); + } + + return result; + } +} +``` + ## SaveChanges interception > [!TIP] @@ -750,3 +963,390 @@ Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2 Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!' Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'. ``` + +### Example: Optimistic concurrency interception + +EF Core supports the [optimistic concurrency pattern](xref:core/saving/concurrency) by checking that the number of rows actually affected by an update or delete is the same as the number of rows expected to be affected. This is often coupled with a concurrency token; that is, a column value that will only match its expected value if the row has not been updated since the expected value was read. + +EF signals a violation of optimistic concurrency by throwing a . has methods `ThrowingConcurrencyException` and `ThrowingConcurrencyExceptionAsync` that are called before the `DbUpdateConcurrencyException` is thrown. These interception points allow the exception to be suppressed, possibly coupled with async database changes to resolve the violation. + +For example, if two requests attempt to delete the same entity at almost the same time, then the second delete may fail because the row in the database no longer exists. This may be fine--the end result is that the entity has been deleted anyway. The following interceptor demonstrates how this can be done: + +```csharp +public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor +{ + public InterceptionResult ThrowingConcurrencyException( + ConcurrencyExceptionEventData eventData, + InterceptionResult result) + { + if (eventData.Entries.All(e => e.State == EntityState.Deleted)) + { + Console.WriteLine("Suppressing Concurrency violation for command:"); + Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText); + + return InterceptionResult.Suppress(); + } + + return result; + } + + public ValueTask ThrowingConcurrencyExceptionAsync( + ConcurrencyExceptionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + => new(ThrowingConcurrencyException(eventData, result)); +} +``` + +There are several things worth noting about this interceptor: + +* Both the synchronous and asynchronous interception methods are implemented. This is important if the application may call either `SaveChanges` or `SaveChangesAsync`. However, if all application code is async, then only `ThrowingConcurrencyExceptionAsync` needs to be implemented. Likewise, if the application never uses asynchronous database methods, then only `ThrowingConcurrencyException` needs to be implemented. This is generally true for all interceptors with sync and async methods. +* The interceptor has access to objects for the entities being saved. In this case, this is used to check whether or not the concurrency violation is happening for a delete operation. +* If the application is using a relational database provider, then the object can be cast to a object. This provides additional, relational-specific information about the database operation being performed. In this case, the relational command text is printed to the console. +* Returning `InterceptionResult.Suppress()` tells EF Core to suppress the action it was about to take--in this case, throwing the `DbUpdateConcurrencyException`. This ability to _change the behavior of EF Core_, rather than just observing what EF Core is doing, is one of the most powerful features of interceptors. + +## Materialization interception + + supports interception before and after an entity instance is created, and before and after properties of that instance are initialized. The interceptor can change or replace the entity instance at each point. This allows: + +* Setting unmapped properties or calling methods needed for validation, computed values, or flags. +* Using a factory to create instances. +* Creating a different entity instance than EF would normally create, such as an instance from a cache, or of a proxy type. +* Injecting services into an entity instance. + +> [!NOTE] +> `IMaterializationInterceptor` is a singleton interceptor, meaning a single instance is shared between all `DbContext` instances. + +### Example: Simple actions on entity creation + +Imagine that we want to keep track of the time that an entity was retrieved from the database, perhaps so it can be displayed to a user editing the data. To accomplish this, we first define an interface: + +```csharp +public interface IHasRetrieved +{ + DateTime Retrieved { get; set; } +} +``` + +Using an interface is common with interceptors since it allows the same interceptor to work with many different entity types. For example: + +```csharp +public class Customer : IHasRetrieved +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? PhoneNumber { get; set; } + + [NotMapped] + public DateTime Retrieved { get; set; } +} +``` + +Notice that the `[NotMapped]` attribute is used to indicate that this property is used only while working with the entity, and should not be persisted to the database. + +The interceptor must then implement the appropriate method from `IMaterializationInterceptor` and set the time retrieved: + +```csharp +public class SetRetrievedInterceptor : IMaterializationInterceptor +{ + public object InitializedInstance(MaterializationInterceptionData materializationData, object instance) + { + if (instance is IHasRetrieved hasRetrieved) + { + hasRetrieved.Retrieved = DateTime.UtcNow; + } + + return instance; + } +} +``` + +An instance of this interceptor is registered when configuring the `DbContext`: + +```csharp +public class CustomerContext : DbContext +{ + private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new(); + + public DbSet Customers => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .AddInterceptors(_setRetrievedInterceptor) + .UseSqlite("Data Source = customers.db"); +} +``` + +> [!TIP] +> This interceptor is stateless, which is common, so a single instance is created and shared between all `DbContext` instances. + +Now, whenever a `Customer` is queried from the database, the `Retrieved` property will be set automatically. For example: + +```csharp +await using (var context = new CustomerContext()) +{ + var customer = await context.Customers.SingleAsync(e => e.Name == "Alice"); + Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'"); +} +``` + +Produces output: + +```output +Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM' +``` + +### Example: Injecting services into entities + +EF Core already has built-in support for injecting some special services into context instances; for example, see [Lazy loading without proxies](xref:core/querying/related-data/lazy#lazy-loading-without-proxies), which works by injecting the `ILazyLoader` service. + +An `IMaterializationInterceptor` can be used to generalize this to any service. The following example shows how to inject an into entities such that they can perform their own logging. + +> [!NOTE] +> Injecting services into entities couples those entity types to the injected services, which some people consider to be an anti-pattern. + +As before, an interface is used to define what can be done. + +```csharp +public interface IHasLogger +{ + ILogger? Logger { get; set; } +} +``` + +And entity types that will log must implement this interface. For example: + +```csharp +public class Customer : IHasLogger +{ + private string? _phoneNumber; + + public int Id { get; set; } + public string Name { get; set; } = null!; + + public string? PhoneNumber + { + get => _phoneNumber; + set + { + Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'."); + + _phoneNumber = value; + } + } + + [NotMapped] + public ILogger? Logger { get; set; } +} +``` + +This time, the interceptor must implement `IMaterializationInterceptor.InitializedInstance`, which is called after every entity instance has been created and its property values have been initialized. The interceptor obtains an `ILogger` from the context and initializes `IHasLogger.Logger` with it: + +```csharp +public class LoggerInjectionInterceptor : IMaterializationInterceptor +{ + private ILogger? _logger; + + public object InitializedInstance(MaterializationInterceptionData materializationData, object instance) + { + if (instance is IHasLogger hasLogger) + { + _logger ??= materializationData.Context.GetService().CreateLogger("CustomersLogger"); + hasLogger.Logger = _logger; + } + + return instance; + } +} +``` + +This time a new instance of the interceptor is used for each `DbContext` instance, since the `ILogger` obtained can change per `DbContext` instance, and the `ILogger` is cached on the interceptor: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor()); +``` + +Now, whenever the `Customer.PhoneNumber` is changed, this change will be logged to the application's log. For example: + +```output +info: CustomersLogger[1] + Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'. +``` + +## Query expression interception + + allows interception of the [LINQ expression tree](xref:core/querying/how-query-works) for a query before it is compiled. This can be used to dynamically modify queries in ways that apply across the application. + +> [!NOTE] +> `IQueryExpressionInterceptor` is a singleton interceptor, meaning a single instance is typically shared between all `DbContext` instances. + +> [!WARNING] +> Interceptors are powerful, but it's easy to get things wrong when working with expression trees. Always consider if there is an easier way of achieving what you want, such as modifying the query directly. + +### Example: Inject ordering into queries for stable sorting + +Consider a method that returns a page of customers: + +```csharp +Task> GetPageOfCustomers(string sortProperty, int page) +{ + using var context = new CustomerContext(); + + return context.Customers + .OrderBy(e => EF.Property(e, sortProperty)) + .Skip(page * 20).Take(20).ToListAsync(); +} +``` + +> [!TIP] +> This query uses the method to specify the property to sort by. This allows the application to dynamically pass in the property name, allowing sorting by any property of the entity type. Be aware that sorting by non-indexed columns can be slow. + +This will work fine as long as the property used for sorting always returns a stable ordering. But this may not always be the case. For example, the LINQ query above generates the following on SQLite when ordering by `Customer.City`: + +```sql +SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" +FROM "Customers" AS "c" +ORDER BY "c"."City" +LIMIT @__p_1 OFFSET @__p_0 +``` + +If there are multiple customers with the same `City`, then the ordering of this query is not stable. This could lead to missing or duplicate results as the user pages through the data. + +A common way to fix this problem is to perform a secondary sorting by primary key. However, rather than manually adding this to every query, an interceptor can add the secondary ordering dynamically. To facilitate this, we define an interface for any entity that has an integer primary key: + +```csharp +public interface IHasIntKey +{ + int Id { get; } +} +``` + +This interface is implemented by the entity types of interest: + +```csharp +public class Customer : IHasIntKey +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? City { get; set; } + public string? PhoneNumber { get; set; } +} +``` + +We then need an interceptor that implements : + +```csharp +public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor +{ + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) + => new KeyOrderingExpressionVisitor().Visit(queryExpression); + + private class KeyOrderingExpressionVisitor : ExpressionVisitor + { + private static readonly MethodInfo ThenByMethod + = typeof(Queryable).GetMethods() + .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2); + + protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression) + { + var methodInfo = methodCallExpression!.Method; + if (methodInfo.DeclaringType == typeof(Queryable) + && methodInfo.Name == nameof(Queryable.OrderBy) + && methodInfo.GetParameters().Length == 2) + { + var sourceType = methodCallExpression.Type.GetGenericArguments()[0]; + if (typeof(IHasIntKey).IsAssignableFrom(sourceType)) + { + var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand; + var entityParameterExpression = lambdaExpression.Parameters[0]; + + return Expression.Call( + ThenByMethod.MakeGenericMethod( + sourceType, + typeof(int)), + methodCallExpression, + Expression.Lambda( + typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)), + Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)), + entityParameterExpression)); + } + } + + return base.VisitMethodCall(methodCallExpression); + } + } +} +``` + +This probably looks pretty complicated--and it is! Working with expression trees is typically not easy. Let's look at what's happening: + +* Fundamentally, the interceptor encapsulates an . The visitor overrides , which will be called whenever there is a call to a method in the query expression tree. + +* The visitor checks whether or not this is a call to the method we are interested in. +* If it is, then the visitor further checks if the generic method call is for a type that implements our `IHasIntKey` interface. +* At this point we know that the method call is of the form `OrderBy(e => ...)`. We extract the lambda expression from this call and get the parameter used in that expression--that is, the `e`. +* We now build a new using the builder method. In this case, the method being called is `ThenBy(e => e.Id)`. We build this using the parameter extracted above and a property access to the `Id` property of the `IHasIntKey` interface. +* The input into this call is the original `OrderBy(e => ...)`, and so the end result is an expression for `OrderBy(e => ...).ThenBy(e => e.Id)`. +* This modified expression is returned from the visitor, which means the LINQ query has now been appropriately modified to include a `ThenBy` call. +* EF Core continues and compiles this query expression into the appropriate SQL for the database being used. + +Registering this interceptor and executing `GetPageOfCustomers` now generates the following SQL: + +```sql +SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" +FROM "Customers" AS "c" +ORDER BY "c"."City", "c"."Id" +LIMIT @__p_1 OFFSET @__p_0 +``` + +This will now always produce a stable ordering, even if there are multiple customers with the same `City`. + +In many cases, the same thing can be achieved more simply by modifying the query directly. For example: + +```csharp +Task> GetPageOfCustomers2(string sortProperty, int page) +{ + using var context = new CustomerContext(); + + return context.Customers + .OrderBy(e => EF.Property(e, sortProperty)) + .ThenBy(e => e.Id) + .Skip(page * 20).Take(20).ToListAsync(); +} +``` + +In this case the `ThenBy` is simply added to the query. Yes, it may need to be done separately to every query, but it's simple, easy to understand, and will always work. + +## Identity resolution interception + + allows interception of identity resolution conflicts when the starts tracking new entity instances. + +> [!NOTE] +> This interceptor is currently only called when `DbContext.Update`, `DbContext.Attach`, and similar methods are used to track entities that are already being tracked with the same key. It is not called for entities returned from queries. This may change in a future release; [see this issue](https://github.com/dotnet/efcore/issues/37574). + +A `DbContext` can only track one entity instance with any given primary key value. This means multiple instances of an entity with the same key value must be resolved to a single instance. An interceptor of this type is called with the existing tracked instance and the new instance and must apply any property values and relationship changes from the new instance into the existing instance. The new instance is then discarded. + +EF Core provides a built-in implementation, , which updates the existing tracked entity with values from the new instance. This can be registered when configuring the context: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .AddInterceptors(new UpdatingIdentityResolutionInterceptor()); +``` + +To implement custom identity resolution logic, create a class that implements `IIdentityResolutionInterceptor` and override the `UpdateTrackedInstance` method: + +```csharp +public class CustomIdentityResolutionInterceptor : IIdentityResolutionInterceptor +{ + public void UpdateTrackedInstance( + IdentityResolutionInterceptionData interceptionData, + EntityEntry existingEntry, + object newEntity) + { + // Custom logic to merge property values from newEntity into the existing tracked entity + existingEntry.CurrentValues.SetValues(newEntity); + } +} +``` diff --git a/entity-framework/core/managing-schemas/migrations/projects.md b/entity-framework/core/managing-schemas/migrations/projects.md index a306bde830..6d9f101a14 100644 --- a/entity-framework/core/managing-schemas/migrations/projects.md +++ b/entity-framework/core/managing-schemas/migrations/projects.md @@ -8,7 +8,7 @@ uid: core/managing-schemas/migrations/projects # Using a Separate Migrations Project -You may want to store your migrations in a different project than the one containing your `DbContext`. You can also use this strategy to maintain multiple sets of migrations, for example, one for development and another for release-to-release upgrades. +You may want to store your migrations in a different project than the one containing your `DbContext`. This is recommended if your project is platform-specific, since EF tools don't support these projects. You can also use this strategy to maintain multiple sets of migrations, for example, one for development and another for release-to-release upgrades. > [!TIP] > You can view this article's [sample on GitHub](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Schemas/ThreeProjectMigrations). diff --git a/entity-framework/core/managing-schemas/scaffolding/index.md b/entity-framework/core/managing-schemas/scaffolding/index.md index 6cf14174c5..4ea02e0f0c 100644 --- a/entity-framework/core/managing-schemas/scaffolding/index.md +++ b/entity-framework/core/managing-schemas/scaffolding/index.md @@ -18,7 +18,7 @@ Reverse engineering is the process of scaffolding entity type classes and a [DbC ## Prerequisites -- Before scaffolding, you'll need to install either the [PMC tools](xref:core/cli/powershell), which work on Visual Studio only, or the [.NET CLI tools](xref:core/cli/dotnet), which across all platforms supported by .NET. +- Before scaffolding, you'll need to install either the [PMC tools](xref:core/cli/powershell), which work on Visual Studio only, or the [.NET CLI tools](xref:core/cli/dotnet), which work across all platforms supported by .NET. - Install the NuGet package for `Microsoft.EntityFrameworkCore.Design` in the project you are scaffolding to. - Install the NuGet package for the [database provider](xref:core/providers/index) that targets the database schema you want to scaffold from. diff --git a/entity-framework/core/modeling/entity-types.md b/entity-framework/core/modeling/entity-types.md index 983370b689..a89541f7d5 100644 --- a/entity-framework/core/modeling/entity-types.md +++ b/entity-framework/core/modeling/entity-types.md @@ -92,7 +92,7 @@ Entity types can be mapped to database views using the Fluent API. [!code-csharp[Main](../../../samples/core/Modeling/EntityTypes/FluentAPI/ViewNameAndSchema.cs?name=ViewNameAndSchema&highlight=1)] - Mapping to a view will remove the default table mapping, but the entity type can also be mapped to a table explicitly. In this case the query mapping will be used for queries and the table mapping will be used for updates. + Mapping to a view will remove the default table mapping, but the entity type can also be mapped to a table explicitly. In this case the view mapping will be used for queries and the table mapping will be used for updates. > [!TIP] > To test keyless entity types mapped to views using the in-memory provider, map them to a query via . See the [in-memory provider docs](xref:core/testing/testing-without-the-database#in-memory-provider) for more information. diff --git a/entity-framework/core/providers/cosmos/limitations.md b/entity-framework/core/providers/cosmos/limitations.md index 26cc0f153c..e51f59be17 100644 --- a/entity-framework/core/providers/cosmos/limitations.md +++ b/entity-framework/core/providers/cosmos/limitations.md @@ -11,6 +11,7 @@ The Azure Cosmos DB database provider targets the Azure Cosmos DB NoSQL store, w Common EF Core patterns that either do not apply, or are a pit-of-failure, when using a document database include: +- Azure Cosmos DB does not provide relational-style transactions or full ACID guarantees across any number of operations and tables. However, the EF Core Azure Cosmos DB provider does use [Transactional batches](/azure/cosmos-db/transactional-batch) when possible; see [Saving data](xref:core/providers/cosmos/saving) for further details. - Schema migration is not supported, since there is no defined schema for the documents. However, there could be other mechanisms for dealing with evolving data shapes that do make sense with Azure Cosmos DB NoSQL, For example, [Schema versioning pattern with Azure Cosmos DB](https://github.com/dotnet/efcore/issues/23753), and [Azure Cosmos DB data migration](https://github.com/dotnet/efcore/issues/11099). - Reverse-engineering (scaffolding) a model from an existing database is not supported. Again, this is not supported because there is no defined database schema to scaffold from. However, see [Use shape of documents in the Azure Cosmos DB database to scaffold a schema](https://github.com/dotnet/efcore/issues/30290). - Schema concepts defined on the EF model, like indexes and constraints, are ignored when using a document database, since there is no schema. Note that Azure Cosmos DB NoSQL performs [automatic indexing of documents](/azure/cosmos-db/index-overview). diff --git a/entity-framework/core/providers/cosmos/saving.md b/entity-framework/core/providers/cosmos/saving.md new file mode 100644 index 0000000000..b2f1da1a7c --- /dev/null +++ b/entity-framework/core/providers/cosmos/saving.md @@ -0,0 +1,179 @@ +--- +title: Saving Data - Azure Cosmos DB Provider - EF Core +description: Saving data with the Azure Cosmos DB EF Core Provider +author: roji +ms.date: 02/02/2026 +uid: core/providers/cosmos/saving +--- +# Saving Data with the EF Core Azure Cosmos DB Provider + +## Saving basics + +Saving data with the Azure Cosmos DB provider works in a similar fashion to other providers. You add, modify or remove entities, and then call to persist those changes to the database. For more information on the basics of saving, see [Saving data](xref:core/saving/index). + +## Transactionality and transactional batches + +> [!NOTE] +> Transactional batches support is being introduced in EF Core 11, which is currently in preview. + +Azure Cosmos DB provides limited support for atomic transactions; this is a common limitation of document databases, where the focus is on scalability and availability rather than strict transactional semantics. Azure Cosmos DB does support [transactional batches](/azure/cosmos-db/transactional-batch), which allow operations to be executed together as a batch within a single partition (see additional limitations below). Atomicity is guaranteed within a single batch: if any operation fails, the entire batch is rolled back and none of its changes are applied. However, once a batch is written, it cannot be rolled back or deferred, and atomicity cannot be enforced across multiple batches. Transactional batches also provide a performance benefit, as multiple documents can be updated in a single roundtrip, rather than performing a roundtrip for each update. + +Starting with EF 11, the EF Core Azure Cosmos DB provider leverages transactional batches by default whenever possible, providing a best-effort approximation of atomicity (and optimal performance) when saving changes. The batching behavior can be controlled by the property, allowing developers to trade off between performance, consistency guarantees, and failure behavior depending on the application’s needs. + +* **Auto** (default) – EF automatically groups operations into transactional batches, which are executed sequentially. If a batch fails, execution stops immediately and no subsequent changes are saved, while any previously successful batches remain committed. This generally provides good performance when performing multiple operations that can be grouped by partition key value, with a best-effort approximation of atomicity. +* **Never** – All operations are performed individually and sequentially, in the exact order they were tracked. This avoids batching and can be slower, especially for large numbers of changes. This was the behavior prior to version 11. +* **Always** – Requires that all operations can be executed as a single transaction batch; if any operation cannot be included in a batch (see [Limitations](#limitations)), an exception is thrown. This allows you to guarantee full atomicity (and a single roundtrip) when executing `SaveChangesAsync`, but it is then up to you to manually ensure that all operations can be performed in a transactional batch. + +Here is an example of using , which causes `SaveChangesAsync` to fail because too many operations are attempted: + +```csharp +using var context = new BlogsContext(); +context.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always; +context.AddRange(Enumerable.Range(0, 101).Select(i => new Post())); // 101 entries exceeds the batch item limit of 100. +await context.SaveChangesAsync(); // Throws InvalidOperationException since the changes cannot be saved in a single batch. +``` + +### Limitations + +* Transactional batches can only be performed within a single partition. +* Transactional batches can contain only up to 100 operations, and cannot surpass 2MB of data in total. +* Azure Cosmos DB does not allow document writes with [pre- or post-triggers](/azure/cosmos-db/stored-procedures-triggers-udfs#triggers) to be part of a transactional batch. Because of this, any entities configured with triggers are executed separately and before any transactional batches. This can affect ordering and consistency in mixed scenarios. + +## Bulk execution + +> [!NOTE] +> Bulk execution is being introduced in EF Core 11, which is currently in preview. + +Prior to version 11, the Azure Cosmos DB provider executed document operations sequentially when calling `SaveChangesAsync`; when saving a large number of entities, this was slow as each operation waits for the previous one to complete before starting. Version 11 enables [transactional batches](#transactionality-and-transactional-batches), which allow operations to be batched together for better performance. However, transactional batches can only be used against a single partition, and have various limitations (see the documentation above). + +An alternative approach is to use Azure Cosmos DB supports _bulk execution_, which allows multiple document operations to be executed in parallel and across DbContext instances, significantly improving throughput when saving many entities at once. This is especially useful for data loading scenarios, batch operations, or any situation where you need to save many entities. + +To enable bulk execution, configure your context using the `BulkExecutionEnabled()` option: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseCosmos( + "https://localhost:8081", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + databaseName: "OrdersDB", + options => options.BulkExecutionEnabled()); +``` + +With bulk execution enabled, document write operations are executed in parallel rather than sequentially. This can provide significant performance improvements when saving multiple entities, especially in scenarios involving hundreds or thousands of documents. However, make sure to understand [the caveats](https://devblogs.microsoft.com/cosmosdb/introducing-bulk-support-in-the-net-sdk/#what-are-the-caveats) before enabling this. Note that the behavior of `SaveChangesAsync` remains the same from an API perspective; it's only the internal execution that changes to take advantage of Azure Cosmos DB's bulk capabilities. + +For more information on bulk execution in Azure Cosmos DB, see the following Azure documentation: + +* [Introducing bulk support in the .NET SDK](https://devblogs.microsoft.com/cosmosdb/introducing-bulk-support-in-the-net-sdk/) +* [Bulk updates with optimistic concurrency control](https://devblogs.microsoft.com/cosmosdb/bulk-updates-with-optimistic-concurrency-control/) + +## Session token management + +> [!NOTE] +> Session token management is being introduced in EF Core 11, which is currently in preview. + +Azure Cosmos DB uses [session tokens](/azure/cosmos-db/consistency-levels#session-consistency) to track read-your-writes consistency within a session. When using session consistency (the default), each read or write operation returns a session token that can be used in subsequent requests to ensure that the requesting client reads its own writes. + +By default, the Azure Cosmos DB .NET SDK manages session tokens automatically within a single `CosmosClient` instance. However, in some scenarios you may need to manage session tokens manually: + +* When your application uses a round-robin load balancer that doesn't maintain session affinity between HTTP requests +* When session tokens need to be persisted and restored across application restarts +* When session tokens need to be shared between different `DbContext` instances or application processes + +### Configuring session token management mode + +To enable manual session token management, configure `SessionTokenManagementMode()` when setting up your context: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseCosmos( + "", + databaseName: "OrdersDB", + options => options.SessionTokenManagementMode(SessionTokenManagementMode.SemiAutomatic)); +``` + +The following modes are available: + +Mode | Description +-------------------|------------ +`FullyAutomatic` | The default mode. Uses the underlying Cosmos DB SDK automatic session token management. `GetSessionTokens` and `UseSessionTokens` methods will throw when invoked. +`SemiAutomatic` | Allows overwriting the SDK's automatic session token management via `UseSessionTokens`. If `UseSessionTokens` has not been called for a container, the SDK's automatic management is used. EF tracks session tokens which can be retrieved via `GetSessionTokens`. +`Manual` | Fully replaces SDK automatic session token management. Only session tokens specified via `UseSessionTokens` or tracked by EF operations are used. +`EnforcedManual` | Same as `Manual`, but throws an exception if `UseSessionTokens` wasn't called for the container before executing an operation on it. + +### Retrieving session tokens + +After performing read or write operations, you can retrieve the session tokens tracked by EF: + +```csharp +// Get the session token for the default container +string? sessionToken = context.Database.GetSessionToken(); + +// Get session tokens for all containers +IReadOnlyDictionary allTokens = context.Database.GetSessionTokens(); +``` + +### Setting session tokens + +Before performing read operations, you can set session tokens to ensure you read your own writes: + +```csharp +// Set the session token for the default container +context.Database.UseSessionToken(sessionToken); + +// Set session tokens for all containers +context.Database.UseSessionTokens(sessionTokens); +``` + +### Appending session tokens + +You can also append session tokens to the existing ones. This is useful when you have session tokens from multiple sources and want to combine them: + +```csharp +// Append a session token for the default container +context.Database.AppendSessionToken(sessionToken); + +// Append session tokens for the specified containers +context.Database.AppendSessionTokens(sessionTokens); +``` + +### Example: Session token management in a load-balanced web application + +The following example demonstrates how to manage session tokens in a web application with multiple instances behind a round-robin load balancer: + +```csharp +// After writing data, retrieve and store the session token (e.g., in a cookie or header) +public async Task CreateDocument(DocumentDto dto) +{ + await using var context = new MyCosmosContext(); + + var document = new Document { Name = dto.Name }; + context.Documents.Add(document); + await context.SaveChangesAsync(); + + // Retrieve the session token to return to the client + var sessionToken = context.Database.GetSessionToken(); + + // Store in response header for the client to send back in subsequent requests + Response.Headers["x-cosmos-session-token"] = sessionToken; + + return Ok(document.Id); +} + +// Before reading data, apply the session token from the client +public async Task GetDocument(int id) +{ + await using var context = new MyCosmosContext(); + + // Get the session token from the request header + if (Request.Headers.TryGetValue("x-cosmos-session-token", out var sessionToken)) + { + context.Database.UseSessionToken(sessionToken!); + } + + var document = await context.Documents.FindAsync(id); + return document is not null ? Ok(document) : NotFound(); +} +``` + +> [!WARNING] +> Manual session token management can break session consistency when not handled properly. Only use manual mode when you have a specific need, such as running behind a load balancer without session affinity. For more information, see [Utilize session tokens](/azure/cosmos-db/nosql/how-to-manage-consistency?tabs=portal%2Cdotnetv2%2Capi-async#utilize-session-tokens) in the Azure Cosmos DB documentation. diff --git a/entity-framework/core/what-is-new/ef-core-11.0/whatsnew.md b/entity-framework/core/what-is-new/ef-core-11.0/whatsnew.md new file mode 100644 index 0000000000..d3c56b5829 --- /dev/null +++ b/entity-framework/core/what-is-new/ef-core-11.0/whatsnew.md @@ -0,0 +1,169 @@ +--- +title: What's New in EF Core 11 +description: Overview of new features in EF Core 11 +author: roji +ms.date: 02/02/2026 +uid: core/what-is-new/ef-core-11.0/whatsnew +--- + +# What's New in EF Core 11 + +EF Core 11.0 (EF11) is the next release after EF Core 10.0 and is currently in development. + +> [!TIP] +> You can run and debug into the samples by [downloading the sample code from GitHub](https://github.com/dotnet/EntityFramework.Docs). Each section below links to the source code specific to that section. + +EF11 requires the .NET 11 SDK to build and requires the .NET 11 runtime to run. EF11 will not run on earlier .NET versions, and will not run on .NET Framework. + +## Complex types + + + +### Complex types and JSON columns on entity types with TPT/TPC inheritance + +EF Core has supported complex types and JSON columns for several versions, allowing you to model and persist nested, structured data within your entities. However, until now these features could not be used on entities with TPT (table-per-type) or TPC (table-per-concrete-type) inheritance. + +Starting with EF 11, you can now use complex types and JSON columns on entity types with TPT and TPC inheritance mappings. For example, consider the following entity types with a TPT inheritance strategy: + +```csharp +public abstract class Animal +{ + public int Id { get; set; } + public string Name { get; set; } + public required AnimalDetails Details { get; set; } +} + +public class Dog : Animal +{ + public string Breed { get; set; } +} + +public class Cat : Animal +{ + public bool IsIndoor { get; set; } +} + +[ComplexType] +public class AnimalDetails +{ + public DateTime BirthDate { get; set; } + public string? Veterinarian { get; set; } +} +``` + +With the following TPT mapping configuration: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity().UseTptMappingStrategy(); +} +``` + +EF 11 now properly supports this scenario, creating the `Details_BirthDate` and `Details_Veterinarian` columns on the `Animal` table for the complex type properties. + +Similarly, JSON columns are now supported with TPT/TPC. The following configuration maps the `Details` property to a JSON column: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .UseTptMappingStrategy() + .ComplexProperty(a => a.Details, b => b.ToJson()); +} +``` + +This enhancement removes a significant limitation when modeling complex domain hierarchies, allowing you to combine the flexibility of TPT/TPC inheritance with the power of complex types and JSON columns. + +For more information on inheritance mapping strategies, see [Inheritance](xref:core/modeling/inheritance). + +## Cosmos DB + + + +### Transactional batches + +Azure Cosmos DB supports [transactional batches](/azure/cosmos-db/transactional-batch), which allow multiple operations to be executed atomically and in a single roundtrip against a single partition. Starting with EF Core 11, the provider leverages transactional batches by default, providing best-effort atomicity and improved performance when saving changes. + +The batching behavior can be controlled via the property: + +* **Auto** (default): Operations are grouped into transactional batches by container and partition. Batches are executed sequentially; if a batch fails, subsequent batches are not executed. +* **Never**: All operations are performed individually and sequentially (the pre-11 behavior). +* **Always**: Requires all operations to fit in a single transactional batch; throws if they cannot. + +For more information, [see the documentation](xref:core/providers/cosmos/saving#transactionality-and-transactional-batches). + +This feature was contributed by [@JoasE](https://github.com/JoasE) - many thanks! + + + +### Bulk execution + +Azure Cosmos DB supports _bulk execution_, which allows multiple document operations to be executed in parallel and across DbContext instances, significantly improving throughput when saving many entities at once. EF Core now supports enabling bulk execution: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseCosmos( + "", + databaseName: "OrdersDB", + options => options.BulkExecutionEnabled()); +``` + +For more information, [see the documentation](xref:core/providers/cosmos/saving#bulk-execution). + +This feature was contributed by [@JoasE](https://github.com/JoasE) - many thanks! + + + +### Session token management + +Azure Cosmos DB uses session tokens to track read-your-writes consistency within a session. When running in an environment with multiple instances (e.g., with round-robin load balancing), you may need to manually manage session tokens to ensure consistency across requests. + +EF Core now provides APIs to retrieve and set session tokens on a `DbContext`. To enable manual session token management, configure the `SessionTokenManagementMode()`: + +```csharp +protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseCosmos( + "", + databaseName: "OrdersDB", + options => options.SessionTokenManagementMode(SessionTokenManagementMode.SemiAutomatic)); +``` + +You can then retrieve and use session tokens: + +```csharp +// After performing operations, retrieve the session token +var sessionToken = context.Database.GetSessionToken(); + +// Later, in a different context instance, apply the session token before reading +context.Database.UseSessionToken(sessionToken); +var result = await context.Documents.FindAsync(id); +``` + +For more information, [see the documentation](xref:core/providers/cosmos/saving#session-token-management). + +This feature was contributed by [@JoasE](https://github.com/JoasE) - many thanks! + +## Migrations + + + +### Create and apply migrations in one step + +The `dotnet ef database update` command now supports creating and applying a migration in a single step using the new `--add` option. This uses Roslyn to compile the migration at runtime, enabling scenarios like .NET Aspire and containerized applications where recompilation isn't possible: + +```dotnetcli +dotnet ef database update InitialCreate --add +``` + +This command scaffolds a new migration named `InitialCreate`, compiles it using Roslyn, and immediately applies it to the database. The migration files are still saved to disk for source control and future recompilation. The same options available for `dotnet ef migrations add` can be used: + +```dotnetcli +dotnet ef database update AddProducts --add --output-dir Migrations/Products --namespace MyApp.Migrations +``` + +In PowerShell, use the `-Add` parameter: + +```powershell +Update-Database -Migration InitialCreate -Add +``` diff --git a/entity-framework/core/what-is-new/ef-core-6.0/whatsnew.md b/entity-framework/core/what-is-new/ef-core-6.0/whatsnew.md index 9fd95244cd..b33f1dbe0e 100644 --- a/entity-framework/core/what-is-new/ef-core-6.0/whatsnew.md +++ b/entity-framework/core/what-is-new/ef-core-6.0/whatsnew.md @@ -3428,9 +3428,9 @@ This validation can be disabled if necessary. For example: GitHub Issue: [#23719](https://github.com/dotnet/efcore/issues/23719). This feature was contributed by [@Giorgi](https://github.com/Giorgi). Many thanks! -The `CommandEventData` supplied to diagnostics sources and interceptors now contains an enum value indicating which part of EF was responsible for creating the command. This can be used as a filter in the diagnostics or interceptor. For example, we may want an interceptor that only applies to commands that come from `SaveChanges`: +The supplied to diagnostics sources and interceptors now contains a enum value indicating which part of EF was responsible for creating the command. This can be used to filter interceptor behavior, for example to only intercept commands originating from `SaveChanges`: - -[!code-csharp[Interceptor](../../../../samples/core/Miscellaneous/NewInEFCore6/CommandSourceSample.cs?name=Interceptor)] +``` This filters the interceptor to only `SaveChanges` events when used in an application which also generates migrations and queries. For example: @@ -3462,6 +3461,8 @@ FROM [Customers] WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity(); ``` +For more information, see [Filtering by command source](xref:core/logging-events-diagnostics/interceptors#filtering-by-command-source). + ### Better temporary values handling GitHub Issue: [#24245](https://github.com/dotnet/efcore/issues/24245). 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 b8934f010a..2a9cda3770 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 @@ -2320,48 +2320,13 @@ In addition, EF7 includes new traditional .NET events for: - When an [entity is about to be tracked or change state](https://github.com/dotnet/efcore/issues/27093), but before it is actually tracked or change state - Before and after EF Core [detects changes to entities and properties](https://github.com/dotnet/efcore/issues/26506) (aka `DetectChanges` interception) -The following sections show some examples of using these new interception capabilities. +The following sections provide brief summaries of these new interception capabilities. For detailed documentation and complete code samples, see [Interceptors](xref:core/logging-events-diagnostics/interceptors). -### Simple actions on entity creation +### Materialization interception -> [!TIP] -> The code shown here comes from [SimpleMaterializationSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs). - -The new supports interception before and after an entity instance is created, and before and after properties of that instance are initialized. The interceptor can change or replace the entity instance at each point. This allows: +The new supports interception before and after an entity instance is created, and before and after properties of that instance are initialized. The interceptor can change or replace the entity instance at each point. This allows setting unmapped properties, using a factory to create instances, injecting services into entities, and more. -- Setting unmapped properties or calling methods needed for validation, computed values, or flags. -- Using a factory to create instances. -- Creating a different entity instance than EF would normally create, such as an instance from a cache, or of a proxy type. -- Injecting services into an entity instance. - -For example, imagine that we want to keep track of the time that an entity was retrieved from the database, perhaps so it can be displayed to a user editing the data. To accomplish this, we first define an interface: - - -[!code-csharp[IHasRetrieved](../../../../samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs?name=IHasRetrieved)] - -Using an interface is common with interceptors since it allows the same interceptor to work with many different entity types. For example: - - -[!code-csharp[Customer](../../../../samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs?name=Customer)] - -Notice that the `[NotMapped]` attribute is used to indicate that this property is used only while working with the entity, and should not be persisted to the database. - -The interceptor must then implement the appropriate method from `IMaterializationInterceptor` and set the time retrieved: +For example, an interceptor can set a `Retrieved` timestamp on entities as they are loaded from the database: [!code-csharp[SetRetrievedInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs?name=SetRetrievedInterceptor)] -An instance of this interceptor is registered when configuring the `DbContext`: - - -[!code-csharp[CustomerContext](../../../../samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs?name=CustomerContext)] - -> [!TIP] -> This interceptor is stateless, which is common, so a single instance is created and shared between all `DbContext` instances. - -Now, whenever a `Customer` is queried from the database, the `Retrieved` property will be set automatically. For example: - - -[!code-csharp[QueryCustomer](../../../../samples/core/Miscellaneous/NewInEFCore7/SimpleMaterializationSample.cs?name=QueryCustomer)] - -Produces output: - -```output -Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM' -``` - -### Injecting services into entities - -> [!TIP] -> The code shown here comes from [InjectLoggerSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs). - -EF Core already has built-in support for injecting some special services into context instances; for example, see [Lazy loading without proxies](xref:core/querying/related-data/lazy#lazy-loading-without-proxies), which works by injecting the `ILazyLoader` service. - -An `IMaterializationInterceptor` can be used to generalize this to any service. The following example shows how to inject an into entities such that they can perform their own logging. - -> [!NOTE] -> Injecting services into entities couples those entity types to the injected services, which some people consider to be an anti-pattern. - -As before, an interface is used to define what can be done. - - -[!code-csharp[IHasLogger](../../../../samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs?name=IHasLogger)] - -And entity types that will log must implement this interface. For example: - - -[!code-csharp[CustomerIHasLogger](../../../../samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs?name=CustomerIHasLogger)] - -This time, the interceptor must implement `IMaterializationInterceptor.InitializedInstance`, which is called after every entity instance has been created and its property values have been initialized. The interceptor obtains an `ILogger` from the context and initializes `IHasLogger.Logger` with it: - - -[!code-csharp[LoggerInjectionInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs?name=LoggerInjectionInterceptor)] - -This time a new instance of the interceptor is used for each `DbContext` instance, since the `ILogger` obtained can change per `DbContext` instance, and the `ILogger` is cached on the interceptor: - - -[!code-csharp[OnConfiguring](../../../../samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs?name=OnConfiguring)] - -Now, whenever the `Customer.PhoneNumber` is changed, this change will be logged to the application's log. For example: - -```output -info: CustomersLogger[1] - Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'. -``` +For more information and examples, including injecting services into entities, see [Materialization interception](xref:core/logging-events-diagnostics/interceptors#materialization-interception). ### LINQ expression tree interception -> [!TIP] -> The code shown here comes from [QueryInterceptionSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs). - -EF Core makes use of [.NET LINQ queries](xref:core/querying/how-query-works). This typically involves using the C#, VB, or F# compiler to build an expression tree which is then translated by EF Core into the appropriate SQL. For example, consider a method that returns a page of customers: - - -[!code-csharp[GetPageOfCustomers](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs?name=GetPageOfCustomers)] - -> [!TIP] -> This query uses the method to specify the property to sort by. This allows the application to dynamically pass in the property name, allowing sorting by any property of the entity type. Be aware that sorting by non-indexed columns can be slow. - -This will work fine as long as the property used for sorting always returns a stable ordering. But this may not always be the case. For example, the LINQ query above generates the following on SQLite when ordering by `Customer.City`: - -```sql -SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" -FROM "Customers" AS "c" -ORDER BY "c"."City" -LIMIT @__p_1 OFFSET @__p_0 -``` - -If there are multiple customers with the same `City`, then the ordering of this query is not stable. This could lead to missing or duplicate results as the user pages through the data. - -A common way to fix this problem is to perform a secondary sorting by primary key. However, rather than manually adding this to every query, EF7 allows interception of the query expression tree where the secondary ordering can be added dynamically. To facilitate this, we will again use an interface, this time for any entity that has an integer primary key: - - -[!code-csharp[IHasIntKey](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs?name=IHasIntKey)] - -This interface is implemented by the entity types of interest: - - -[!code-csharp[CustomerIHasIntKey](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs?name=CustomerIHasIntKey)] - -We then need an interceptor that implements - - -[!code-csharp[KeyOrderingExpressionInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs?name=KeyOrderingExpressionInterceptor)] - -This probably looks pretty complicated--and it is! Working with expression trees is typically not easy. Let's look at what's happening: - -- Fundamentally, the interceptor encapsulates an . The visitor overrides , which will be called whenever there is a call to a method in the query expression tree. - -- The visitor checks whether or not this is a call to the method we are interested in. -- If it is, then the visitor further checks if the generic method call is for a type that implements our `IHasIntKey` interface. -- At this point we know that the method call is of the form `OrderBy(e => ...)`. We extract the lambda expression from this call and get the parameter used in that expression--that is, the `e`. -- We now build a new using the builder method. In this case, the method being called is `ThenBy(e => e.Id)`. We build this using the parameter extracted above and a property access to the `Id` property of the `IHasIntKey` interface. -- The input into this call is the original `OrderBy(e => ...)`, and so the end result is an expression for `OrderBy(e => ...).ThenBy(e => e.Id)`. -- This modified expression is returned from the visitor, which means the LINQ query has now been appropriately modified to include a `ThenBy` call. -- EF Core continues and compiles this query expression into the appropriate SQL for the database being used. - -This interceptor is registered in the same way as we did for the first example. Executing `GetPageOfCustomers` now generates the following SQL: +```csharp +public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor +{ + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) + => new KeyOrderingExpressionVisitor().Visit(queryExpression); -```sql -SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" -FROM "Customers" AS "c" -ORDER BY "c"."City", "c"."Id" -LIMIT @__p_1 OFFSET @__p_0 + // ExpressionVisitor that modifies the tree... +} ``` -This will now always produce a stable ordering, even if there are multiple customers with the same `City`. - -Phew! That's a lot of code to make a simple change to a query. And even worse, it might not even work for all queries. It is notoriously difficult to write an expression visitor that recognizes all the query shapes it should, and none of the ones it should not. For example, this will likely not work if the ordering is done in a subquery. - -This brings us to a critical point about interceptors--always ask yourself if there is an easier way of doing what you want. Interceptors are powerful, but it's easy to get things wrong. They are, as the saying goes, an easy way to shoot yourself in the foot. - -For example, imagine if we instead changed our `GetPageOfCustomers` method like so: - - -[!code-csharp[GetPageOfCustomers2](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs?name=GetPageOfCustomers2)] - -In this case the `ThenBy` is simply added to the query. Yes, it may need to be done separately to every query, but it's simple, easy to understand, and will always work. +For more information and examples, see [Query expression interception](xref:core/logging-events-diagnostics/interceptors#query-expression-interception). ### Optimistic concurrency interception -> [!TIP] -> The code shown here comes from [OptimisticConcurrencyInterceptionSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/OptimisticConcurrencyInterceptionSample.cs). - -EF Core supports the [optimistic concurrency pattern](xref:core/saving/concurrency) by checking that the number of rows actually affected by an update or delete is the same as the number of rows expected to be affected. This is often coupled with a concurrency token; that is, a column value that will only match its expected value if the row has not been updated since the expected value was read. + now has `ThrowingConcurrencyException` and `ThrowingConcurrencyExceptionAsync` methods that are called before a is thrown. These interception points allow the exception to be suppressed, possibly coupled with async database changes to resolve the violation. -EF signals a violation of optimistic concurrency by throwing a . In EF7, has new methods `ThrowingConcurrencyException` and `ThrowingConcurrencyExceptionAsync` that are called before the `DbUpdateConcurrencyException` is thrown. These interception points allow the exception to be suppressed, possibly coupled with async database changes to resolve the violation. +For example, if two requests try to delete the same entity, the second delete fails because the row no longer exists. An interceptor can suppress this, since the end result is the same: -For example, if two requests attempt to delete the same entity at almost the same time, then the second delete may fail because the row in the database no longer exists. This may be fine--the end result is that the entity has been deleted anyway. The following interceptor demonstrates how this can be done: - - -[!code-csharp[SuppressDeleteConcurrencyInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/OptimisticConcurrencyInterceptionSample.cs?name=SuppressDeleteConcurrencyInterceptor)] - -There are several things worth noting about this interceptor: +} +``` -- Both the synchronous and asynchronous interception methods are implemented. This is important if the application may call either `SaveChanges` or `SaveChangesAsync`. However, if all application code is async, then only `ThrowingConcurrencyExceptionAsync` needs to be implemented. Likewise, if the application never uses asynchronous database methods, then only `ThrowingConcurrencyException` needs to be implemented. This is generally true for all interceptors with sync and async methods. (It might be worthwhile implementing the method your application does not use to throw, just in case some sync/async code creeps in.) -- The interceptor has access to objects for the entities being saved. In this case, this is used to check whether or not the concurrency violation is happening for a delete operation. -- If the application is using a relational database provider, then the object can be cast to a object. This provides additional, relational-specific information about the database operation being performed. In this case, the relational command text is printed to the console. -- Returning `InterceptionResult.Suppress()` tells EF Core to suppress the action it was about to take--in this case, throwing the `DbUpdateConcurrencyException`. This ability to _change the behavior of EF Core_, rather than just observing what EF Core is doing, is one of the most powerful features of interceptors. +For more information and examples, see [Optimistic concurrency interception](xref:core/logging-events-diagnostics/interceptors#example-optimistic-concurrency-interception). ### Lazy initialization of a connection string -> [!TIP] -> The code shown here comes from [LazyConnectionStringSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/LazyConnectionStringSample.cs). - -Connection strings are often static assets read from a configuration file. These can easily be passed to `UseSqlServer` or similar when configuring a `DbContext`. However, sometimes the connection string can change for each context instance. For example, each tenant in a multi-tenant system may have a different connection string. - -EF7 makes it easier to handle dynamic connections and connection strings through improvements to the . This starts with the ability to configure the `DbContext` without any connection string. For example: - -```csharp -services.AddDbContext( - b => b.UseSqlServer()); -``` + can be used to dynamically configure connection strings at the time the connection is first used, for example to support per-tenant connection strings in a multi-tenant system. The `ConnectionOpeningAsync` method can perform an async operation to obtain the connection string, find an access token, and so on. -One of the `IDbConnectionInterceptor` methods can then be implemented to configure the connection before it is used. `ConnectionOpeningAsync` is a good choice, since it can perform an async operation to obtain the connection string, find an access token, and so on. For example, imagine a service scoped to the current request that understands the current tenant: +For example, the following interceptor sets the connection string the first time the connection is opened: ```csharp -services.AddScoped(); -``` - -> [!WARNING] -> Performing an asynchronous lookup for a connection string, access token, or similar every time it is needed can be very slow. Consider caching these things and only refreshing the cached string or token periodically. For example, access tokens can often be used for a significant period of time before needing to be refreshed. - -This can be injected into each `DbContext` instance using constructor injection: - -```csharp -public class CustomerContext : DbContext +public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor { private readonly ITenantConnectionStringFactory _connectionStringFactory; - public CustomerContext( - DbContextOptions options, - ITenantConnectionStringFactory connectionStringFactory) - : base(options) + public ConnectionStringInitializationInterceptor(ITenantConnectionStringFactory connectionStringFactory) { _connectionStringFactory = connectionStringFactory; } - // ... -} -``` - -This service is then used when constructing the interceptor implementation for the context: - -```csharp -protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.AddInterceptors( - new ConnectionStringInitializationInterceptor(_connectionStringFactory)); -``` - -Finally, the interceptor uses this service to obtain the connection string asynchronously and set it the first time that the connection is used: - - -[!code-csharp[ConnectionStringInitializationInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/LazyConnectionStringSample.cs?name=ConnectionStringInitializationInterceptor)] - -> [!NOTE] -> The connection string is only obtained the first time that a connection is used. After that, the connection string stored on the `DbConnection` will be used without looking up a new connection string. +} +``` -> [!TIP] -> This interceptor overrides the non-async `ConnectionOpening` method to throw since the service to get the connection string must be called from an async code path. +For more information and examples, see [Lazy initialization of a connection string](xref:core/logging-events-diagnostics/interceptors#example-lazy-initialization-of-a-connection-string). ### Logging SQL Server query statistics -> [!TIP] -> The code shown here comes from [QueryStatisticsLoggerSample.cs](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs). - -Finally, let's create two interceptors that work together to send SQL Server query statistics to the application log. To generate the statistics, we need an to do two things. +Two interceptors can work together to send SQL Server query statistics to the application log. An prefixes commands with `SET STATISTICS IO ON` and uses the new `DataReaderClosingAsync` method to read statistics results. An uses the new `ConnectionCreated` method to hook into the event. -First, the interceptor will prefix commands with `SET STATISTICS IO ON`, which tells SQL Server to send statistics to the client after a result set has been consumed: - - -[!code-csharp[ReaderExecutingAsync](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs?name=ReaderExecutingAsync)] - -Second, the interceptor will implement the new `DataReaderClosingAsync` method, which is called after the has finished consuming results, but _before_ it has been closed. When SQL Server is sending statistics, it puts them in a second result on the reader, so at this point the interceptor reads that result by calling `NextResultAsync` which populates statistics onto the connection. - - -[!code-csharp[DataReaderClosingAsync](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs?name=DataReaderClosingAsync)] - -The second interceptor is needed to obtain the statistics from the connection and write them out to the application's logger. For this, we'll use an , implementing the new `ConnectionCreated` method. `ConnectionCreated` is called immediately after EF Core has created a connection, and so can be used to perform additional configuration of that connection. In this case, the interceptor obtains an `ILogger` and then hooks into the event to log the messages. - - -[!code-csharp[InfoMessageInterceptor](../../../../samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs?name=InfoMessageInterceptor)] - -> [!IMPORTANT] -> The `ConnectionCreating` and `ConnectionCreated` methods are only called when EF Core creates a `DbConnection`. They will not be called if the application creates the `DbConnection` and passes it to EF Core. - -Running some code that uses these interceptors show SQL Server query statistics in the log: +```csharp +public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) +{ + command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText; -```output -info: Microsoft.EntityFrameworkCore.Database.Command[20101] - Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30'] - SET STATISTICS IO ON; - SET IMPLICIT_TRANSACTIONS OFF; - SET NOCOUNT ON; - MERGE [Customers] USING ( - VALUES (@p0, @p1, 0), - (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0 - WHEN NOT MATCHED THEN - INSERT ([Name], [PhoneNumber]) - VALUES (i.[Name], i.[PhoneNumber]) - OUTPUT INSERTED.[Id], i._Position; -info: InfoMessageLogger[1] - Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0. -info: Microsoft.EntityFrameworkCore.Database.Command[20101] - Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] - SET STATISTICS IO ON; - SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber] - FROM [Customers] AS [c] - WHERE [c].[Name] = N'Alice' -info: InfoMessageLogger[1] - Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0. + return new(result); +} ``` +For more information and examples, see [Logging SQL Server query statistics](xref:core/logging-events-diagnostics/interceptors#example-logging-sql-server-query-statistics). + ## Query enhancements EF7 contains many improvements in the translation of LINQ queries. diff --git a/entity-framework/toc.yml b/entity-framework/toc.yml index c355b57198..560304cc47 100644 --- a/entity-framework/toc.yml +++ b/entity-framework/toc.yml @@ -61,6 +61,14 @@ href: core/what-is-new/index.md - name: Release planning process href: core/what-is-new/release-planning.md + - name: EF Core 11.0 + items: + - name: "What's new?" + href: core/what-is-new/ef-core-11.0/whatsnew.md + - name: Breaking changes + href: core/what-is-new/ef-core-11.0/breaking-changes.md + - name: Provider-facing changes + href: core/what-is-new/ef-core-11.0/provider-facing-changes.md - name: EF Core 10.0 items: - name: "What's new?" @@ -442,6 +450,8 @@ href: core/providers/cosmos/modeling.md - name: Querying href: core/providers/cosmos/querying.md + - name: Saving data + href: core/providers/cosmos/saving.md - name: Work with unstructured data href: core/providers/cosmos/unstructured-data.md - name: Vector search diff --git a/samples/core/Miscellaneous/NewInEFCore6/CommandSourceSample.cs b/samples/core/Miscellaneous/NewInEFCore6/CommandSourceSample.cs deleted file mode 100644 index bbc7b304a5..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore6/CommandSourceSample.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Data.Common; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; - -public static class CommandSourceSample -{ - public static async Task Interceptors_get_the_source_of_the_command() - { - Console.WriteLine($">>>> Sample: {nameof(Interceptors_get_the_source_of_the_command)}"); - Console.WriteLine(); - - using var context = new CustomersContext(); - - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - context.Add( - new Customer - { - Name = "Sam Vimes" - }); - - await context.SaveChangesAsync(); - - context.ChangeTracker.Clear(); - - var customers = await context.Customers.ToListAsync(); - - Console.WriteLine(); - } - - #region Interceptor - public class CommandSourceInterceptor : DbCommandInterceptor - { - public override InterceptionResult ReaderExecuting( - DbCommand command, CommandEventData eventData, InterceptionResult result) - { - if (eventData.CommandSource == CommandSource.SaveChanges) - { - Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:"); - Console.WriteLine(); - Console.WriteLine(command.CommandText); - } - - return result; - } - } - #endregion - - public class Customer - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class CustomersContext : DbContext - { - public DbSet Customers { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder - .EnableSensitiveDataLogging() - .AddInterceptors(new CommandSourceInterceptor()) - .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EFCoreSample;ConnectRetryCount=0"); - } - } -} diff --git a/samples/core/Miscellaneous/NewInEFCore6/Program.cs b/samples/core/Miscellaneous/NewInEFCore6/Program.cs index baed816a8e..826dc646c6 100644 --- a/samples/core/Miscellaneous/NewInEFCore6/Program.cs +++ b/samples/core/Miscellaneous/NewInEFCore6/Program.cs @@ -40,7 +40,6 @@ public static async Task Main() await HasConversionSample.Can_set_value_converter_type_using_generic_method(); MinimalApiSample.Add_a_DbContext_and_provider(); await ToInMemoryQuerySample.Can_query_keyless_types_from_in_memory_database(); - await CommandSourceSample.Interceptors_get_the_source_of_the_command(); await ScaffoldingSample.Reverse_engineer_from_database(); await ManyToManyConfigurationSample.Many_to_many_relationships_may_need_less_configuration(); await ConvertNullsSample.Value_converters_can_convert_nulls(); diff --git a/samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs b/samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs deleted file mode 100644 index 8c98e118b7..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore7/InjectLoggerSample.cs +++ /dev/null @@ -1,113 +0,0 @@ -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; - -namespace NewInEfCore7; - -public static class InjectLoggerSample -{ - public static async Task Injecting_services_into_entities() - { - PrintSampleName(); - - var loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); }); - - var serviceProvider = new ServiceCollection() - .AddDbContext( - b => b.UseLoggerFactory(loggerFactory) - .UseSqlite("Data Source = customers.db")) - .BuildServiceProvider(); - - using (var scope = serviceProvider.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - await context.AddRangeAsync( - new Customer { Name = "Alice", PhoneNumber = "+1 515 555 0123" }, - new Customer { Name = "Mac", PhoneNumber = "+1 515 555 0124" }); - - await context.SaveChangesAsync(); - } - - using (var scope = serviceProvider.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - var customer = await context.Customers.SingleAsync(e => e.Name == "Alice"); - customer.PhoneNumber = "+1 515 555 0125"; - } - } - - private static void PrintSampleName([CallerMemberName] string? methodName = null) - { - Console.WriteLine($">>>> Sample: {methodName}"); - Console.WriteLine(); - } - - public class CustomerContext : DbContext - { - public CustomerContext(DbContextOptions options) - : base(options) - { - } - - public DbSet Customers - => Set(); - - #region OnConfiguring - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor()); - #endregion - } - - #region LoggerInjectionInterceptor - public class LoggerInjectionInterceptor : IMaterializationInterceptor - { - private ILogger? _logger; - - public object InitializedInstance(MaterializationInterceptionData materializationData, object instance) - { - if (instance is IHasLogger hasLogger) - { - _logger ??= materializationData.Context.GetService().CreateLogger("CustomersLogger"); - hasLogger.Logger = _logger; - } - - return instance; - } - } - #endregion - - #region IHasLogger - public interface IHasLogger - { - ILogger? Logger { get; set; } - } - #endregion - - #region CustomerIHasLogger - public class Customer : IHasLogger - { - private string? _phoneNumber; - - public int Id { get; set; } - public string Name { get; set; } = null!; - - public string? PhoneNumber - { - get => _phoneNumber; - set - { - Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'."); - - _phoneNumber = value; - } - } - - [NotMapped] - public ILogger? Logger { get; set; } - } - #endregion -} diff --git a/samples/core/Miscellaneous/NewInEFCore7/LazyConnectionStringSample.cs b/samples/core/Miscellaneous/NewInEFCore7/LazyConnectionStringSample.cs deleted file mode 100644 index e2a8d2dd95..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore7/LazyConnectionStringSample.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace NewInEfCore7; - -public static class LazyConnectionStringSample -{ - public static async Task Lazy_initialization_of_a_connection_string() - { - PrintSampleName(); - - var services = new ServiceCollection(); - - services.AddScoped(); - - services.AddDbContext( - b => b.UseSqlServer() - .LogTo(Console.WriteLine, LogLevel.Information) - .EnableSensitiveDataLogging()); - - var serviceProvider = services.BuildServiceProvider(); - - using (var scope = serviceProvider.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - await context.AddRangeAsync( - new Customer { Name = "Alice" }, - new Customer { Name = "Mac" }); - - await context.SaveChangesAsync(); - - var customer = await context.Customers.SingleAsync(e => e.Name == "Alice"); - Console.WriteLine(); - Console.WriteLine($"Loaded {customer.Name}"); - Console.WriteLine(); - } - } - - private static void PrintSampleName([CallerMemberName] string? methodName = null) - { - Console.WriteLine($">>>> Sample: {methodName}"); - Console.WriteLine(); - } - - public class CustomerContext : DbContext - { - private readonly IClientConnectionStringFactory _connectionStringFactory; - - public CustomerContext( - DbContextOptions options, - IClientConnectionStringFactory connectionStringFactory) - : base(options) - { - _connectionStringFactory = connectionStringFactory; - } - - public DbSet Customers - => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.AddInterceptors( - new ConnectionStringInitializationInterceptor(_connectionStringFactory)); - } - - public interface IClientConnectionStringFactory - { - Task GetConnectionStringAsync(CancellationToken cancellationToken); - } - - public class TestClientConnectionStringFactory : IClientConnectionStringFactory - { - public Task GetConnectionStringAsync(CancellationToken cancellationToken) - { - Console.WriteLine(); - Console.WriteLine(">>> Getting connection string..."); - Console.WriteLine(); - return Task.FromResult(@"Server=(localdb)\mssqllocaldb;Database=LazyConnectionStringSample"); - } - } - - #region ConnectionStringInitializationInterceptor - public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor - { - private readonly IClientConnectionStringFactory _connectionStringFactory; - - public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory) - { - _connectionStringFactory = connectionStringFactory; - } - - public override InterceptionResult ConnectionOpening( - DbConnection connection, - ConnectionEventData eventData, - InterceptionResult result) - => throw new NotSupportedException("Synchronous connections not supported."); - - public override async ValueTask ConnectionOpeningAsync( - DbConnection connection, ConnectionEventData eventData, InterceptionResult result, - CancellationToken cancellationToken = new()) - { - if (string.IsNullOrEmpty(connection.ConnectionString)) - { - connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken)); - } - - return result; - } - } - #endregion - - public class Customer - { - public int Id { get; set; } - public string Name { get; set; } = null!; - } -} diff --git a/samples/core/Miscellaneous/NewInEFCore7/OptimisticConcurrencyInterceptionSample.cs b/samples/core/Miscellaneous/NewInEFCore7/OptimisticConcurrencyInterceptionSample.cs deleted file mode 100644 index 901e54ad69..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore7/OptimisticConcurrencyInterceptionSample.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace NewInEfCore7; - -public static class OptimisticConcurrencyInterceptionSample -{ - public static async Task Optimistic_concurrency_interception() - { - PrintSampleName(); - - await using (var context = new CustomerContext()) - { - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - await context.AddRangeAsync( - new Customer { Name = "Bill" }, - new Customer { Name = "Bob" }); - - await context.SaveChangesAsync(); - } - - await using (var context1 = new CustomerContext()) - { - var customer1 = await context1.Customers.SingleAsync(e => e.Name == "Bill"); - - await using (var context2 = new CustomerContext()) - { - var customer2 = await context1.Customers.SingleAsync(e => e.Name == "Bill"); - context2.Entry(customer2).State = EntityState.Deleted; - await context2.SaveChangesAsync(); - } - - context1.Entry(customer1).State = EntityState.Deleted; - await context1.SaveChangesAsync(); - } - } - - private static void PrintSampleName([CallerMemberName] string? methodName = null) - { - Console.WriteLine($">>>> Sample: {methodName}"); - Console.WriteLine(); - } - - public class CustomerContext : DbContext - { - private static readonly SuppressDeleteConcurrencyInterceptor _concurrencyInterceptor = new(); - - public DbSet Customers - => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .AddInterceptors(_concurrencyInterceptor) - .UseSqlite("Data Source = customers.db") - .LogTo(Console.WriteLine, LogLevel.Information); - } - - #region SuppressDeleteConcurrencyInterceptor - public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor - { - public InterceptionResult ThrowingConcurrencyException( - ConcurrencyExceptionEventData eventData, - InterceptionResult result) - { - if (eventData.Entries.All(e => e.State == EntityState.Deleted)) - { - Console.WriteLine("Suppressing Concurrency violation for command:"); - Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText); - - return InterceptionResult.Suppress(); - } - - return result; - } - - public ValueTask ThrowingConcurrencyExceptionAsync( - ConcurrencyExceptionEventData eventData, - InterceptionResult result, - CancellationToken cancellationToken = default) - => new(ThrowingConcurrencyException(eventData, result)); - } - #endregion - - public class Customer - { - public int Id { get; set; } - public string Name { get; set; } = null!; - } -} diff --git a/samples/core/Miscellaneous/NewInEFCore7/Program.cs b/samples/core/Miscellaneous/NewInEFCore7/Program.cs index d7c07a4960..ec587c479f 100644 --- a/samples/core/Miscellaneous/NewInEFCore7/Program.cs +++ b/samples/core/Miscellaneous/NewInEFCore7/Program.cs @@ -35,16 +35,6 @@ public static async Task Main() await SimpleMaterializationSample.Simple_actions_on_entity_creation(); - await QueryInterceptionSample.LINQ_expression_tree_interception(); - - await OptimisticConcurrencyInterceptionSample.Optimistic_concurrency_interception(); - - await InjectLoggerSample.Injecting_services_into_entities(); - - await LazyConnectionStringSample.Lazy_initialization_of_a_connection_string(); - - await QueryStatisticsLoggerSample.Executing_commands_after_consuming_a_result_set(); - await UngroupedColumnsQuerySample.Subqueries_dont_reference_ungrouped_columns_from_outer_query_SqlServer(); await GroupByEntityTypeSample.GroupBy_entity_type_Sqlite(); diff --git a/samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs b/samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs deleted file mode 100644 index 77e202bcd6..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore7/QueryInterceptionSample.cs +++ /dev/null @@ -1,138 +0,0 @@ -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace NewInEfCore7; - -public static class QueryInterceptionSample -{ - public static async Task LINQ_expression_tree_interception() - { - PrintSampleName(); - - await using (var context = new CustomerContext()) - { - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - await context.AddRangeAsync( - new Customer { Name = "Alice", PhoneNumber = "+1 515 555 0123", City = "Ames" }, - new Customer { Name = "Mac", PhoneNumber = "+1 515 555 0124", City = "Ames" }, - new Customer { Name = "Toast" }, - new Customer { Name = "Baxter" }); - - await context.SaveChangesAsync(); - } - - foreach (var customer in await GetPageOfCustomers("City", 0)) - { - Console.WriteLine($"{customer.Name}"); - } - - #region GetPageOfCustomers - Task> GetPageOfCustomers(string sortProperty, int page) - { - using var context = new CustomerContext(); - - return context.Customers - .OrderBy(e => EF.Property(e, sortProperty)) - .Skip(page * 20).Take(20).ToListAsync(); - } - #endregion - - foreach (var customer in await GetPageOfCustomers2("City", 0)) - { - Console.WriteLine($"{customer.Name}"); - } - - #region GetPageOfCustomers2 - Task> GetPageOfCustomers2(string sortProperty, int page) - { - using var context = new CustomerContext(); - - return context.Customers - .OrderBy(e => EF.Property(e, sortProperty)) - .ThenBy(e => e.Id) - .Skip(page * 20).Take(20).ToListAsync(); - } - #endregion - } - - private static void PrintSampleName([CallerMemberName] string? methodName = null) - { - Console.WriteLine($">>>> Sample: {methodName}"); - Console.WriteLine(); - } - - public class CustomerContext : DbContext - { - private static readonly KeyOrderingExpressionInterceptor _keyOrderingExpressionInterceptor = new(); - - public DbSet Customers - => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .AddInterceptors(_keyOrderingExpressionInterceptor) - .UseSqlite("Data Source = customers.db") - .LogTo(Console.WriteLine, LogLevel.Information); - } - - #region KeyOrderingExpressionInterceptor - public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor - { - public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) - => new KeyOrderingExpressionVisitor().Visit(queryExpression); - - private class KeyOrderingExpressionVisitor : ExpressionVisitor - { - private static readonly MethodInfo ThenByMethod - = typeof(Queryable).GetMethods() - .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2); - - protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression) - { - var methodInfo = methodCallExpression!.Method; - if (methodInfo.DeclaringType == typeof(Queryable) - && methodInfo.Name == nameof(Queryable.OrderBy) - && methodInfo.GetParameters().Length == 2) - { - var sourceType = methodCallExpression.Type.GetGenericArguments()[0]; - if (typeof(IHasIntKey).IsAssignableFrom(sourceType)) - { - var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand; - var entityParameterExpression = lambdaExpression.Parameters[0]; - - return Expression.Call( - ThenByMethod.MakeGenericMethod( - sourceType, - typeof(int)), - base.VisitMethodCall(methodCallExpression), - Expression.Lambda( - typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)), - Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)), - entityParameterExpression)); - } - } - - return base.VisitMethodCall(methodCallExpression); - } - } - } - #endregion - - #region IHasIntKey - public interface IHasIntKey - { - int Id { get; } - } - #endregion - - #region CustomerIHasIntKey - public class Customer : IHasIntKey - { - public int Id { get; set; } - public string Name { get; set; } = null!; - public string? City { get; set; } - public string? PhoneNumber { get; set; } - } - #endregion -} diff --git a/samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs b/samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs deleted file mode 100644 index d01287061a..0000000000 --- a/samples/core/Miscellaneous/NewInEFCore7/QueryStatisticsLoggerSample.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; - -namespace NewInEfCore7; - -public static class QueryStatisticsLoggerSample -{ - public static async Task Executing_commands_after_consuming_a_result_set() - { - PrintSampleName(); - - var loggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); }); - - var serviceProvider = new ServiceCollection() - .AddDbContext( - b => b.UseLoggerFactory(loggerFactory) - .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=ConsumedDataReaderSample;ConnectRetryCount=0")) - .BuildServiceProvider(); - - using (var scope = serviceProvider.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - await context.Database.EnsureDeletedAsync(); - await context.Database.EnsureCreatedAsync(); - - await context.AddRangeAsync( - new Customer { Name = "Alice", PhoneNumber = "+1 515 555 0123" }, - new Customer { Name = "Mac", PhoneNumber = "+1 515 555 0124" }); - - await context.SaveChangesAsync(); - } - - using (var scope = serviceProvider.CreateScope()) - { - var context = scope.ServiceProvider.GetRequiredService(); - - _ = await context.Customers.SingleAsync(e => e.Name == "Alice"); - } - } - - private static void PrintSampleName([CallerMemberName] string? methodName = null) - { - Console.WriteLine($">>>> Sample: {methodName}"); - Console.WriteLine(); - } - - public class CustomerContext : DbContext - { - private static readonly StatisticsCommandInterceptor _statisticsCommandInterceptor = new(); - private static readonly InfoMessageInterceptor _infoMessageInterceptor = new(); - - public CustomerContext(DbContextOptions options) - : base(options) - { - } - - public DbSet Customers - => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.AddInterceptors(_statisticsCommandInterceptor, _infoMessageInterceptor); - } - - public class InfoMessageInterceptor : DbConnectionInterceptor - { - #region InfoMessageInterceptor - public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result) - { - var logger = eventData.Context!.GetService().CreateLogger("InfoMessageLogger"); - ((SqlConnection)eventData.Connection).InfoMessage += (_, args) => - { - logger.LogInformation(1, args.Message); - }; - return result; - } - #endregion - } - - public class StatisticsCommandInterceptor : DbCommandInterceptor - { - public override InterceptionResult ReaderExecuting( - DbCommand command, - CommandEventData eventData, - InterceptionResult result) - { - command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText; - - return result; - } - - #region ReaderExecutingAsync - public override ValueTask> ReaderExecutingAsync( - DbCommand command, - CommandEventData eventData, - InterceptionResult result, - CancellationToken cancellationToken = default) - { - command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText; - - return new(result); - } - #endregion - - public override InterceptionResult DataReaderClosing( - DbCommand command, - DataReaderClosingEventData eventData, - InterceptionResult result) - { - eventData.DataReader.NextResult(); - - return result; - } - - #region DataReaderClosingAsync - public override async ValueTask DataReaderClosingAsync( - DbCommand command, - DataReaderClosingEventData eventData, - InterceptionResult result) - { - await eventData.DataReader.NextResultAsync(); - - return result; - } - #endregion - } - - public class Customer - { - public int Id { get; set; } - public string Name { get; set; } = null!; - public string? PhoneNumber { get; set; } - } -}