Skip to content

Issue #31819: Fast DetectChanges; Issue #16491: legacy Merge-Option Feature#37812

Open
DamirLisak wants to merge 2 commits intodotnet:mainfrom
iplus-framework:main
Open

Issue #31819: Fast DetectChanges; Issue #16491: legacy Merge-Option Feature#37812
DamirLisak wants to merge 2 commits intodotnet:mainfrom
iplus-framework:main

Conversation

@DamirLisak
Copy link

@DamirLisak DamirLisak commented Feb 27, 2026

Issue #31819 "DetectChanges slow in long-term contexts" is no longer necessary if all entity classes in the model are defined with a change-tracking strategy other than "ChangeTrackingStrategy.Snapshot." This is controlled in the ProcessModelFinalizing method of the ChangeTrackingStrategyConvention class. DetectChanges is therefore not called when saving because change tracking is performed using the EntityReferenceMap (_entityReferenceMap property), which essentially corresponds to the previous caching mechanism using EntityState dictionaries from EF4/5/6. However, querying this cache was not enabled; instead, users were forced to use ChangeTracker.Entries(), which returned all objects and led to performance problems with large long-term contexts. This change now enables fast access to changed objects using the new GetEntriesForState method in the ChangeTracker class. This, in turn, calls EntityReferenceMap.GetEntriesForState(), which already exists.
Added test for testing ChangeTracker.GetEntries(EntityState)

Issue #16491: Implemented legacy Merge-Option Feature from EF/4/5/6
Added test for testing the Merge-Option Feature

  • I've read the guidelines for contributing and seen the walkthrough
  • I've posted a comment on an issue with a detailed description of how I am planning to contribute and got approval from a member of the team
  • The code builds and tests pass locally (also verified by our automated build checks)
  • Commit messages follow this format:
        Summary of the changes
        - Detail 1
        - Detail 2

        Fixes #bugnumber
  • Tests for the changes have been added (for bug fixes / features)
  • Code follows the same patterns and style as existing code in this repo

Replaces #36556

…onger necessary if all entity classes in the model are defined with a change-tracking strategy other than "ChangeTrackingStrategy.Snapshot." This is controlled in the ProcessModelFinalizing method of the ChangeTrackingStrategyConvention class. DetectChanges is therefore not called when saving because change tracking is performed using the EntityReferenceMap (_entityReferenceMap property), which essentially corresponds to the previous caching mechanism using EntityState dictionaries from EF4/5/6. However, querying this cache was not enabled; instead, users were forced to use ChangeTracker.Entries(), which returned all objects and led to performance problems with large long-term contexts. This change now enables fast access to changed objects using the new GetEntriesForState method in the ChangeTracker class. This, in turn, calls EntityReferenceMap.GetEntriesForState(), which already exists.

For fast change tracking, implement INotifyProperty-Changed on your entity classes and activate this strategy in the Model Builder using ChangeTrackingStrategy.ChangedNotifications via HasChangeTrackingStrategy().
dotnet#31819

Added test for testing ChangeTracker.GetEntries(EntityState)
@DamirLisak DamirLisak requested a review from a team as a code owner February 27, 2026 12:00
Copilot AI review requested due to automatic review settings February 27, 2026 12:00
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to address two issues: #31819 (fast DetectChanges for long-term contexts) and #16491 (legacy Merge-Option feature from EF4/5/6). The implementation adds a new MergeOption enum and Refresh() query extension method that allows refreshing tracked entities with database values, plus new GetEntriesForState() methods on ChangeTracker for fast access to entities by state.

Changes:

  • Adds public API for refreshing entities: MergeOption enum, Refresh() extension method, and overloads for EntityEntry.Reload()
  • Adds ChangeTracker.GetEntriesForState() methods for fast filtered access to tracked entities
  • Modifies query compilation pipeline to support merge options during entity materialization
  • Adds comprehensive test suite for the Merge-Option feature (but not for GetEntriesForState)

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
src/EFCore/MergeOption.cs New enum defining merge behavior (AppendOnly, OverwriteChanges, PreserveChanges)
src/EFCore/Extensions/EntityFrameworkQueryableExtensions.cs Adds Refresh() extension method to enable merge options on queries
src/EFCore/ChangeTracking/ChangeTracker.cs Adds GetEntriesForState() methods for fast state-based entity filtering
src/EFCore/ChangeTracking/EntityEntry.cs Adds Reload() overloads accepting MergeOption parameter
src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs Adds ReloadValue() internal method implementing property-level refresh logic
src/EFCore/Query/QueryCompilationContext.cs Adds RefreshMergeOption property to track merge behavior during compilation
src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs Handles Refresh() method call normalization
src/EFCore/Query/ShapedQueryCompilingExpressionVisitor.cs Implements merge logic during entity materialization
src/EFCore/Properties/CoreStrings.resx Adds error messages for Refresh validation
src/EFCore/Properties/CoreStrings.Designer.cs Generated error message accessors
test/EFCore.Specification.Tests/MergeOptionTestBase.cs Comprehensive test base class for merge options across scenarios
test/EFCore.Sqlite.FunctionalTests/Query/MergeOptionSqliteTest.cs SQLite-specific test implementation
test/EFCore.SqlServer.FunctionalTests/Query/MergeOptionSqlServerTest.cs SQL Server-specific test implementation
Files not reviewed (1)
  • src/EFCore/Properties/CoreStrings.Designer.cs: Language not supported

Comment on lines +218 to +234
/// <summary>
/// Returns tracked entities that are in a given state from a fast cache.
/// </summary>
/// <param name="added">Entities in EntityState.Added state</param>
/// <param name="modified">Entities in Modified.Added state</param>
/// <param name="deleted">Entities in Modified.Deleted state</param>
/// <param name="unchanged">Entities in Modified.Unchanged state</param>
/// <returns>An entry for each entity that matched the search criteria.</returns>
public virtual IEnumerable<EntityEntry> GetEntriesForState(
bool added = false,
bool modified = false,
bool deleted = false,
bool unchanged = false)
{
return StateManager.GetEntriesForState(added, modified, deleted, unchanged)
.Select(e => new EntityEntry(e));
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for this method is incomplete and doesn't follow EF Core documentation conventions. It should include:

  1. A proper summary describing what the method does and when to use it
  2. Remarks section with links to documentation using <see href="https://aka.ms/efcore-docs-*"> format
  3. Proper parameter descriptions using <param> tags
  4. Better description of the state parameters (currently says "Modified.Added" and "Modified.Deleted" which appear to be copy-paste errors - should be "EntityState.Modified" and "EntityState.Deleted")

Compare with the existing Entries() method documentation at lines 183-216 for the expected format.

Copilot uses AI. Check for mistakes.
Comment on lines +759 to +782
private void Reload(PropertyValues? storeValues, MergeOption mergeOption)
{
if (storeValues == null)
{
if (State != EntityState.Added)
{
State = EntityState.Deleted;
State = EntityState.Detached;
}
}
else
{
foreach (var property in Metadata.GetProperties())
{
var value = storeValues[property];
InternalEntry.ReloadValue(property, value, mergeOption, updateEntityState: false);
}

if (mergeOption == MergeOption.OverwriteChanges)
{
State = EntityState.Unchanged;
}
}
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Reload(PropertyValues, MergeOption) method only iterates over properties (line 771) but doesn't handle navigations. The existing Reload(PropertyValues) method (lines 742-758) uses PropertyValues.SetValues() which handles both properties and navigations. Consider whether navigations should also be refreshed, particularly for owned entities and complex types. If navigations should not be refreshed, this should be documented in the method's XML comments.

Copilot uses AI. Check for mistakes.
Comment on lines 1008 to 1023
public virtual void ReloadValue(IPropertyBase propertyBase, object? value, MergeOption mergeOption, bool updateEntityState)
{
var property = (IProperty)propertyBase;
EnsureOriginalValues();
bool isModified = IsModified(property);
_originalValues.SetValue(property, value, -1);
if (mergeOption == MergeOption.OverwriteChanges || !isModified)
SetProperty(propertyBase, value, isMaterialization: true, setModified: false);
if (updateEntityState)
{
if (mergeOption == MergeOption.OverwriteChanges)
SetEntityState(EntityState.Unchanged);
else
((StateManager as StateManager)?.ChangeDetector as ChangeDetector)?.DetectValueChange(this, property);
}
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ReloadValue method calls ChangeDetector.DetectValueChange for individual properties (line 1021), but this may be inefficient when reloading many properties. When updateEntityState is true and mergeOption is PreserveChanges, DetectValueChange is called for every property individually. Consider batching these calls or using a more efficient approach, especially since this is in the hot path during query materialization with Refresh.

Copilot uses AI. Check for mistakes.
Comment on lines +3109 to +3138
/// <summary>
/// Specifies that the current Entity Framework LINQ query should refresh already loaded objects with the specified merge option.
/// </summary>
/// <typeparam name="T">The type of entity being queried.</typeparam>
/// <param name="source">The source query.</param>
/// <param name="mergeOption">The MergeOption</param>
/// <returns>A new query annotated with the given tag.</returns>
public static IQueryable<T> Refresh<T>(
this IQueryable<T> source,
[NotParameterized] MergeOption mergeOption)
{
if (HasNonTrackingOrIgnoreAutoIncludes(source.Expression))
{
throw new InvalidOperationException(CoreStrings.RefreshNonTrackingQuery);
}

if (HasMultipleMergeOptions(source.Expression))
{
throw new InvalidOperationException(CoreStrings.RefreshMultipleMergeOptions);
}
return
source.Provider is EntityQueryProvider
? source.Provider.CreateQuery<T>(
Expression.Call(
instance: null,
method: RefreshMethodInfo.MakeGenericMethod(typeof(T)),
arg0: source.Expression,
arg1: Expression.Constant(mergeOption)))
: source;
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for the Refresh method is incomplete and doesn't follow EF Core documentation conventions. It should include:

  1. A proper remarks section explaining how this method interacts with the change tracker and when to use it
  2. Links to documentation using the <see href="https://aka.ms/efcore-docs-*"> format (not hardcoded learn.microsoft.com URLs)
  3. An example showing how to use this method
  4. Better parameter documentation - the current "The MergeOption" description is not helpful
  5. The return value description says "A new query annotated with the given tag" which is incorrect (copy-pasted from another method) - should describe that it returns a query that will refresh tracked entities when materialized

Copilot uses AI. Check for mistakes.
Comment on lines +808 to +845
// Get all properties to update (exclude key properties which should not change)
var propertiesToUpdate = entityType.GetProperties()
.Where(p => !p.IsPrimaryKey())
.ToList();

var setReloadValueMethod = typeof(InternalEntityEntry)
.GetMethod(nameof(InternalEntityEntry.ReloadValue), new[] { typeof(IPropertyBase), typeof(object), typeof(MergeOption), typeof(bool) })!;

// Update original values similar to EntityEntry.Reload()
// This ensures that the original values snapshot reflects the database state
var dbProperties = propertiesToUpdate;
int count = dbProperties.Count();
int i = 0;
foreach (var property in dbProperties)
{
i++;
var newValue = valueBufferExpression.CreateValueBufferReadValueExpression(
property.ClrType,
property.GetIndex(),
property);

var setOriginalValueExpression = Call(
entryVariable,
setReloadValueMethod,
Constant(property),
property.ClrType.IsValueType && property.IsNullable
? (Expression)Convert(newValue, typeof(object))
: Convert(newValue, typeof(object)),
Constant(_mergeOption),
Constant(i == count));

updateExpressions.Add(setOriginalValueExpression);
}

return updateExpressions.Count > 0
? (Expression)Block(updateExpressions)
: Empty();
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UpdateExistingEntityWithDatabaseValues method only excludes primary key properties when refreshing entity values, but it should also consider:

  1. Foreign key properties that shouldn't change
  2. Properties with ValueGenerated.OnAdd that shouldn't be updated after creation
  3. Concurrency tokens which have special handling

Consider using a pattern similar to the existing Reload() method in EntityEntry which has proper logic for determining which properties should be updated. Also consider whether shadow properties should be handled differently.

Copilot uses AI. Check for mistakes.
Comment on lines +531 to +538
// Update the existing entity with new property values from the database
// if the merge option is not AppendOnly
_mergeOption != MergeOption.AppendOnly
? UpdateExistingEntityWithDatabaseValues(
entryVariable,
concreteEntityTypeVariable,
materializationContextVariable,
shaper) : Empty()),
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UpdateExistingEntityWithDatabaseValues method is called every time an entity is found in the change tracker (lines 533-538), even when MergeOption is AppendOnly. While there's a check _mergeOption != MergeOption.AppendOnly, this creates unnecessary expression tree nodes. Consider checking the merge option earlier to avoid creating the conditional expression entirely when not needed, or restructure the logic to be more efficient.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings February 27, 2026 14:16
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 20 comments.

Files not reviewed (1)
  • src/EFCore/Properties/CoreStrings.Designer.cs: Language not supported

Comment on lines +3493 to +3499
/// Unable to refresh when is not tracked query
/// </summary>
public static string RefreshNonTrackingQuery
=> GetString("RefreshNonTrackingQuery");

/// <summary>
/// Merge option changed on same query
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc summary for this resource property is ungrammatical and doesn't match the actual resource message in the .resx. Since this is a public surface for providers/extensions, update the summary (or regenerate the designer) to avoid misleading documentation.

Suggested change
/// Unable to refresh when is not tracked query
/// </summary>
public static string RefreshNonTrackingQuery
=> GetString("RefreshNonTrackingQuery");
/// <summary>
/// Merge option changed on same query
/// Unable to refresh because the query is not being tracked.
/// </summary>
public static string RefreshNonTrackingQuery
=> GetString("RefreshNonTrackingQuery");
/// <summary>
/// The merge option was changed on the same query.

Copilot uses AI. Check for mistakes.
Comment on lines +222 to +224
/// <param name="modified">Entities in Modified.Added state</param>
/// <param name="deleted">Entities in Modified.Deleted state</param>
/// <param name="unchanged">Entities in Modified.Unchanged state</param>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc parameter descriptions for modified, deleted, and unchanged reference non-existent states like "Modified.Added"/"Modified.Deleted". These should refer to EntityState.Modified, EntityState.Deleted, and EntityState.Unchanged (and ideally use <see cref="EntityState.*" />).

Suggested change
/// <param name="modified">Entities in Modified.Added state</param>
/// <param name="deleted">Entities in Modified.Deleted state</param>
/// <param name="unchanged">Entities in Modified.Unchanged state</param>
/// <param name="modified">Entities in the <see cref="EntityState.Modified" /> state.</param>
/// <param name="deleted">Entities in the <see cref="EntityState.Deleted" /> state.</param>
/// <param name="unchanged">Entities in the <see cref="EntityState.Unchanged" /> state.</param>

Copilot uses AI. Check for mistakes.
Comment on lines +3120 to +3123
if (HasNonTrackingOrIgnoreAutoIncludes(source.Expression))
{
throw new InvalidOperationException(CoreStrings.RefreshNonTrackingQuery);
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refresh relies on HasNonTrackingOrIgnoreAutoIncludes(source.Expression) to enforce tracking. This only catches explicit AsNoTracking*() calls; it won't catch queries that are non-tracking because ChangeTracker.QueryTrackingBehavior is configured globally, making Refresh a silent no-op instead of throwing.

Copilot uses AI. Check for mistakes.
Comment on lines +791 to +795
private Expression UpdateExistingEntityWithDatabaseValues(
ParameterExpression entryVariable,
ParameterExpression concreteEntityTypeVariable,
ParameterExpression materializationContextVariable,
StructuralTypeShaperExpression shaper)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateExistingEntityWithDatabaseValues takes concreteEntityTypeVariable, but it isn't used. If the intent is to refresh values based on the concrete runtime type (e.g. to include derived-type properties when querying a base set), the current implementation won't do that. Either remove the parameter or use it to ensure the refreshed property set matches the concrete entity type.

Copilot uses AI. Check for mistakes.
Comment on lines +3150 to +3151
|| name == nameof(AsNoTrackingWithIdentityResolution)
|| name == nameof(IgnoreAutoIncludes))
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HasNonTrackingOrIgnoreAutoIncludes treats IgnoreAutoIncludes() as equivalent to non-tracking and causes Refresh to throw RefreshNonTrackingQuery. Ignoring auto-includes doesn't disable tracking, so this check (or the exception message) should be adjusted.

Suggested change
|| name == nameof(AsNoTrackingWithIdentityResolution)
|| name == nameof(IgnoreAutoIncludes))
|| name == nameof(AsNoTrackingWithIdentityResolution))

Copilot uses AI. Check for mistakes.
entityType);

/// <summary>
/// Unable to refresh when is not tracked query
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc summary for this resource property is ungrammatical and doesn't match the actual resource message in the .resx. Since this is a public surface for providers/extensions, update the summary (or regenerate the designer) to avoid misleading documentation.

Suggested change
/// Unable to refresh when is not tracked query
/// Message indicating that a query cannot be refreshed because it is not being tracked.

Copilot uses AI. Check for mistakes.
bool modified = false,
bool deleted = false,
bool unchanged = false)
{
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetEntriesForState doesn't call TryDetectChanges() (unlike Entries()/Entries<TEntity>() above). With snapshot tracking this can return stale results unless DetectChanges() was run elsewhere. Either call TryDetectChanges() here or document explicitly that this is a "no DetectChanges" fast-path and may not reflect up-to-date state.

Suggested change
{
{
TryDetectChanges();

Copilot uses AI. Check for mistakes.
UseTransaction(context, ctx =>
{
var product = ctx.Set<Product>().First();
var originalTags = product.Tags.ToList();
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused local originalTags will produce CS0219 (treated as error because TreatWarningsAsErrors=true). Remove it or use it in an assertion.

Suggested change
var originalTags = product.Tags.ToList();

Copilot uses AI. Check for mistakes.
UseTransaction(context, ctx =>
{
var product = ctx.Set<Product>().First();
var currentName = product.Name;
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused local currentName will produce CS0219 (treated as error because TreatWarningsAsErrors=true). Remove it or use it in an assertion.

Suggested change
var currentName = product.Name;

Copilot uses AI. Check for mistakes.
Comment on lines +226 to +230
public virtual IEnumerable<EntityEntry> GetEntriesForState(
bool added = false,
bool modified = false,
bool deleted = false,
bool unchanged = false)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests were found covering the new public ChangeTracker.GetEntriesForState* APIs. Adding tests for each state flag combination (and for behavior when AutoDetectChangesEnabled is true/false) would help prevent regressions, especially since this method intentionally differs from Entries() behavior.

Copilot generated this review using guidance from repository custom instructions.
@roji
Copy link
Member

roji commented Feb 27, 2026

@AndriySvyryd let me know if you want us to discuss this in design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants