Skip to content

Conversation

@roji
Copy link
Member

@roji roji commented Nov 30, 2025

Closes #37247

Description

EF 10 brought various improvements to ExecuteUpdate; among them was #36723, which brought full processing of ExecuteUpdate in our navigation expansion, like any other LINQ operator (previously it was processed as an unknown method, and navigations inside the setter lambdas were not expanded). Unfortunately, with the current implementation, that also means that any pending selector (the Select about just before ExecuteUpdate) flows into the ExecuteUpdate setters. The more complicated setters caused a failure in later processing, specifically in PK-based subquery handling, for natively-unsupported LINQ operators.

See below for an in-depth analysis, for reference.

Customer impact

This bug affects cases where:

  • There's a join before the ExecuteUpdate (possibly some other pending selector types as well).
  • The tree contains operators which aren't natively supported by UPDATE, and so require a PK-based subquery pushdown.
  • There's more than one selector (this triggers the problem in the code handling PK-based subquery pushdown).

Such invocations of ExecuteUpdate throw an exception.

How found

Customer reported on 10.0.0

Regression

Yes.

Testing

Added.

Risk

Low: short, targeted fix to code already significantly modified in 10.0. Quirk added.

@roji
Copy link
Member Author

roji commented Nov 30, 2025

Detailed analysis, for future reference:

In EF 9.0, navigation expansion produced the following tree:

...
DbSet<BookingLeg>()
    .Join(
        inner: DbSet<Booking>(), 
        outerKeySelector: b1 => EF.Property<int?>(b1, "BookingId"), 
        innerKeySelector: b2 => EF.Property<int?>(b2, "Id"), 
        resultSelector: (o, i) => new TransparentIdentifier<BookingLeg, Booking>(
            Outer = o, 
            Inner = i
        ))
    .SelectMany(
        collectionSelector: b1 => DbSet<GateEvent>()
            .Where(g0 => EF.Property<int?>(b1.Inner, "Id") != null && object.Equals(
                objA: (object)EF.Property<int?>(b1.Inner, "Id"), 
                objB: (object)EF.Property<int?>(g0, "BookingId")))
            .Where(g0 => g0.IsInGate), 
        resultSelector: (b1, c) => new TransparentIdentifier<TransparentIdentifier<BookingLeg, Booking>, GateEvent>(
            Outer = b1, 
            Inner = c
        ))
    .Select(ti0 => ti0.Inner)
    .ExecuteUpdate(str => str
        .SetProperty<DateTime?>(
            propertyExpression: p => p.Confirmed, 
            valueExpression: (DateTime?)DateTime.Now)
        .SetProperty<string>(
            propertyExpression: p => p.ConfirmedBy, 
            valueExpression: "Test"))

In 10.0, thanks to #36723, nav expansion now properly handles ExecuteUpdate like a 1st-class LINQ operator - previously it was handled as an unknown method. Unfortunately, with the current implementation, that also means that any pending selector (the Select about just before ExecuteUpdate) flows into the ExecuteUpdate setters; so we get the following tree:

DbSet<BookingLeg>()
    .Join(
        inner: DbSet<Booking>(), 
        outerKeySelector: b => EF.Property<int?>(b, "BookingId"), 
        innerKeySelector: b0 => EF.Property<int?>(b0, "Id"), 
        resultSelector: (o, i) => new TransparentIdentifier<BookingLeg, Booking>(
            Outer = o, 
            Inner = i
        ))
    .SelectMany(
        collectionSelector: b => DbSet<GateEvent>()
            .Where(g => EF.Property<int?>(b.Inner, "Id") != null && object.Equals(
                objA: (object)EF.Property<int?>(b.Inner, "Id"), 
                objB: (object)EF.Property<int?>(g, "BookingId")))
            .Where(g => g.IsInGate), 
        resultSelector: (b, c) => new TransparentIdentifier<TransparentIdentifier<BookingLeg, Booking>, GateEvent>(
            Outer = b, 
            Inner = c
        ))
    .ExecuteUpdate(new Tuple<Delegate, object>[]
    { 
        new Tuple<Delegate, object>(
            ti => ti.Inner.Confirmed, 
            (object)@p
        ), 
        new Tuple<Delegate, object>(
            ti => ti.Inner.ConfirmedBy, 
            @p0
        ) 
    })

While this is technically OK, the more complicated setters cause a failure in later processing. Specifically, the logic to push down complex ExecuteUpdate into a PK-based subquery assumes simple property selectors, where the lambda parameters are all referentially identical across the setter lambdas. but with the pending selector flowing in, we get full MemberExpressions (ti.Inner) instead, which are not referentially identical.

This PR applies any pending selector before processing the ExecuteUpdate setters, adding back the Select() before ExecuteUpdate() and simplifying the setters again.

This bug affects cases where:

  • There's a join before the ExecuteUpdate (possibly some other pending selector types as well).
  • The tree contains operators which aren't natively supported by UPDATE, and so require a PK-based subquery pushdown.
  • There's more than one selector (this triggers the problem in the code handling PK-based subquery pushdown).

@roji roji force-pushed the ExecuteUpdatePendingSelector branch from 39d8caf to f72552b Compare November 30, 2025 17:25
@roji roji marked this pull request as ready for review November 30, 2025 18:05
@roji roji requested a review from a team as a code owner November 30, 2025 18:05
@roji roji requested a review from artl93 November 30, 2025 18:11
Copy link
Member

@artl93 artl93 left a comment

Choose a reason for hiding this comment

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

Customer reported regression. Simple change.

@rbhanda rbhanda added this to the 10.0.2 milestone Dec 2, 2025
@roji roji merged commit ebbd3f9 into dotnet:release/10.0 Dec 2, 2025
7 checks passed
@roji roji deleted the ExecuteUpdatePendingSelector branch December 2, 2025 21:54
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.

5 participants