LEANDRO LAYERLEConsultor · Mentor · Tecnología
Todos los artículos
SERIE · EF Core 10 New Features•PARTE 4•VER SERIE →
Ver repositorio
EF Core 10: ExecuteUpdate sobre columnas JSON sin cargar entidades

En el artículo anterior vimos cómo EF Core 10 permite mapear un Complex Type completo a una única columna JSON:

C#
blog.ComplexProperty(
    x => x.Details,
    details => details.ToJson());

Eso nos permite mantener un modelo C# fuertemente tipado:

TEXT
Blog
└── Details
    ├── Title
    ├── Category
    ├── Views
    └── Featured

mientras SQL Server almacena Details como un documento.

Pero almacenar y consultar JSON es solamente una parte del problema.

La siguiente pregunta es:

¿Qué ocurre cuando necesito actualizar una propiedad dentro de ese documento para muchas filas?

Por ejemplo:

TEXT
Incrementar Views de todos los blogs .NET.

Podríamos cargar todos los blogs, modificar cada objeto y ejecutar SaveChangesAsync().

Pero EF Core tiene otra herramienta para actualizaciones conocidas de antemano:

C#
ExecuteUpdateAsync()

ExecuteUpdate existe desde EF Core 7.

La novedad de EF Core 10 es que ahora podemos utilizarlo también para propiedades dentro de columnas JSON relacionales.


Nuestro modelo

Vamos a utilizar:

C#
public sealed class Blog
{
    public int Id { get; set; }
 
    public required string Name { get; set; }
 
    public required BlogDetails Details { get; set; }
}

BlogDetails:

C#
public sealed class BlogDetails
{
    public required string Title { get; set; }
 
    public required string Category { get; set; }
 
    public int Views { get; set; }
 
    public bool Featured { get; set; }
}

Y lo configuramos como Complex Type almacenado en JSON:

C#
modelBuilder.Entity<Blog>(
    blog =>
    {
        blog.HasKey(x => x.Id);
 
        blog.Property(x => x.Name)
            .IsRequired();
 
        blog.ComplexProperty(
            x => x.Details,
            details => details.ToJson());
    });

La tabla queda conceptualmente así:

TEXT
Blogs
├── Id
├── Name
└── Details

y Details contiene algo equivalente a:

JSON
{
  "Title": "EF Core Performance",
  "Category": ".NET",
  "Views": 10,
  "Featured": false
}

Primero: la forma tradicional con SaveChanges

Supongamos que queremos incrementar las visualizaciones de todos los blogs cuya categoría sea:

TEXT
.NET

Una solución perfectamente válida es:

C#
var blogs =
    await _context.Blogs
        .Where(blog =>
            blog.Details.Category == category)
        .ToListAsync();
 
foreach (var blog in blogs)
{
    blog.Details.Views++;
}
 
await _context.SaveChangesAsync();

Funciona.

Pero observemos el recorrido:

TEXT
base de datos
→ SELECT
→ materializar Blog
→ materializar BlogDetails
→ Change Tracker
→ modificar Views
→ detectar cambios
→ UPDATE

Si necesitábamos mostrar esos blogs, validar reglas complejas sobre los objetos o modificar un grafo, probablemente sea exactamente lo que queremos.

Pero para una actualización masiva simple estamos trayendo información a memoria que no necesitamos utilizar.


ExecuteUpdate cambia el flujo

Con ExecuteUpdateAsync() podemos expresar directamente la modificación.

Por ejemplo:

C#
var affectedRows =
    await _context.Blogs
        .Where(blog =>
            blog.Details.Category == ".NET")
        .ExecuteUpdateAsync(
            setters =>
                setters.SetProperty(
                    blog => blog.Details.Views,
                    100));

La operación dice:

TEXT
Buscar blogs cuya categoría JSON sea .NET
+
poner Details.Views = 100

No existe:

C#
ToListAsync()

Tampoco:

C#
SaveChangesAsync()

ExecuteUpdateAsync() se ejecuta inmediatamente contra la base de datos.


Lo nuevo: la propiedad está dentro de JSON

La parte realmente interesante es:

C#
blog => blog.Details.Views

Views no es una columna relacional normal.

Forma parte de:

TEXT
Details

que está almacenado como JSON.

Hasta EF Core 10, ExecuteUpdateAsync() no soportaba este escenario.

Ahora EF puede traducir el setter sobre una propiedad interna del documento JSON.

La documentación oficial lo resume con esta idea:

C#
await context.Blogs.ExecuteUpdateAsync(
    setters =>
        setters.SetProperty(
            blog => blog.Details.Views,
            blog => blog.Details.Views + 1));

Filtrar también por una propiedad JSON

Nuestro filtro es:

C#
.Where(blog =>
    blog.Details.Category == category)

Es decir, tanto la selección de filas como la propiedad modificada pueden depender del contenido del documento:

TEXT
WHERE
Details.Category
 
SET
Details.Views

Seguimos escribiendo LINQ sobre un modelo tipado.

No trabajamos manualmente con:

TEXT
JSON_VALUE
JSON_MODIFY
modify()

EF y el provider se ocupan de producir la operación adecuada.


SQL Server 2025: modify()

Con SQL Server 2025 y el tipo nativo json, Microsoft documenta que una actualización como:

C#
.SetProperty(
    blog => blog.Details.Views,
    blog => blog.Details.Views + 1)

puede generar SQL equivalente a:

SQL
UPDATE [b]
SET [Details].modify(
    '$.Views',
    JSON_VALUE(
        [b].[Details],
        '$.Views' RETURNING int
    ) + 1
)
FROM [Blogs] AS [b]

La operación se realiza dentro del motor.

No existe un ciclo en C# que materialice cada blog.


¿Y SQL Server anterior a 2025?

EF Core 10 también soporta versiones anteriores de SQL Server, donde el documento se almacena habitualmente como:

TEXT
nvarchar(max)

en lugar del nuevo tipo:

TEXT
json

En esos escenarios el provider utiliza las capacidades JSON disponibles en esa versión del motor.

Por eso el laboratorio incluye primero una prueba muy simple:

C#
.SetProperty(
    blog => blog.Details.Views,
    100)

Esta operación permite comprobar el soporte de ExecuteUpdate sobre la propiedad JSON sin depender de que el nuevo valor lea otra propiedad de la fila.


Incrementar el valor actual

La operación más interesante es:

C#
var affectedRows =
    await _context.Blogs
        .Where(blog =>
            blog.Details.Category == ".NET")
        .ExecuteUpdateAsync(
            setters =>
                setters.SetProperty(
                    blog => blog.Details.Views,
                    blog =>
                        blog.Details.Views + 1));

Ahora el nuevo valor depende del valor actual almacenado dentro del documento:

TEXT
Views = Views + 1

No estamos enviando:

TEXT
Views = 11
Views = 4

desde nuestra aplicación.

La expresión completa se traduce y ejecuta en SQL.

Una consideración para SQL Server

Cuando el valor nuevo de una propiedad JSON depende de una expresión sobre otra columna o sobre el valor actual, el provider de SQL Server necesita capacidades modernas del motor.

Para este tipo de operación, SQL Server 2022 o superior es el escenario recomendado.

En SQL Server 2025, además, podemos aprovechar el nuevo tipo nativo json.


Una sola operación puede afectar muchas filas

Nuestros datos de prueba son:

TEXT
Architecture Notes
Category = .NET
Views = 10
 
Cloud Journal
Category = Cloud
Views = 25
 
Legacy Notes
Category = .NET
Views = 3

Ejecutamos:

C#
await _context.Blogs
    .Where(blog =>
        blog.Details.Category == ".NET")
    .ExecuteUpdateAsync(
        setters =>
            setters.SetProperty(
                blog => blog.Details.Views,
                100));

Resultado:

TEXT
Architecture Notes
Views = 100
 
Cloud Journal
Views = 25
 
Legacy Notes
Views = 100

No cargamos los dos blogs .NET.

La selección y la actualización ocurrieron en la base.


ExecuteUpdate devuelve cuántas filas modificó

El método devuelve un entero:

C#
var affectedRows =
    await query.ExecuteUpdateAsync(...);

En nuestro ejemplo:

TEXT
affectedRows = 2

Esto es útil no solamente para métricas.

También puede utilizarse para implementar controles de concurrencia explícitos.

Por ejemplo:

C#
var affectedRows =
    await _context.Blogs
        .Where(blog =>
            blog.Id == id &&
            blog.Version == expectedVersion)
        .ExecuteUpdateAsync(...);
 
if (affectedRows == 0)
{
    // El registro ya cambió o no existe.
}

A diferencia de SaveChanges, ExecuteUpdate no implementa automáticamente el control de concurrencia mediante el Change Tracker.


Actualizar más de una propiedad del documento

EF Core 10 permite componer setters.

Por ejemplo:

C#
var affectedRows =
    await _context.Blogs
        .Where(blog =>
            blog.Details.Category == ".NET")
        .ExecuteUpdateAsync(
            setters =>
            {
                setters.SetProperty(
                    blog =>
                        blog.Details.Featured,
                    true);
 
                setters.SetProperty(
                    blog =>
                        blog.Details.Views,
                    500);
            });

Conceptualmente:

TEXT
Details.Featured = true
Details.Views = 500

para todas las filas que coincidan con el filtro.

Seguimos sin cargar ninguna entidad.


El Change Tracker no participa

Este punto es especialmente importante.

Supongamos:

C#
var blog =
    await _context.Blogs
        .SingleAsync(
            currentBlog =>
                currentBlog.Id == 1);

El objeto queda trackeado.

Imaginemos que:

TEXT
blog.Details.Views = 10

Ahora ejecutamos:

C#
await _context.Blogs
    .Where(currentBlog =>
        currentBlog.Id == 1)
    .ExecuteUpdateAsync(
        setters =>
            setters.SetProperty(
                currentBlog =>
                    currentBlog.Details.Views,
                110));

La base de datos ahora contiene:

TEXT
Views = 110

Pero la instancia que ya teníamos en memoria continúa mostrando:

TEXT
Views = 10

hasta que hagamos algo como:

C#
await _context.Entry(blog)
    .ReloadAsync();

Después del reload:

TEXT
Views = 110

La razón es simple:

TEXT
ExecuteUpdate
→ no utiliza el Change Tracker

No mezclar sin entender qué está pasando

Esta característica tiene una consecuencia importante.

Podemos tener:

TEXT
base de datos = 110

mientras una entidad trackeada mantiene:

TEXT
objeto en memoria = 10

Si después modificamos esa entidad y ejecutamos SaveChanges(), podemos sobrescribir una actualización realizada mediante ExecuteUpdate.

Por eso conviene evitar mezclar alegremente:

TEXT
entidades trackeadas
+
ExecuteUpdate
+
SaveChanges

dentro del mismo flujo sin una estrategia clara.


SaveChanges y ExecuteUpdate no compiten

No se trata de reemplazar uno con otro.

SaveChanges

Es una buena opción cuando:

TEXT
cargamos una entidad;
el usuario modifica datos;
no sabemos de antemano qué cambió;
trabajamos con grafos;
necesitamos Change Tracker;
queremos concurrencia automática.

ExecuteUpdate

Es especialmente útil cuando:

TEXT
sabemos exactamente qué propiedad cambiar;
tenemos un filtro definido;
podemos afectar muchas filas;
no necesitamos materializar objetos;
queremos expresar un UPDATE directo.

Por ejemplo:

TEXT
Incrementar estadísticas.
Marcar documentos.
Cambiar flags.
Actualizar estados.
Aplicar un valor masivamente.

Una limitación importante: Complex Types, no Owned Types

El soporte de EF Core 10 para ExecuteUpdate sobre JSON tiene una condición explícita.

El objeto debe estar modelado como:

TEXT
Complex Type

No funciona si ese contenido JSON está modelado como:

TEXT
Owned Entity Type

Esta diferencia vuelve a reforzar algo que vimos en el segundo artículo de la serie.

Los Complex Types tienen semántica de valor y EF Core 10 los está convirtiendo en el modelo principal para representar datos documentales dentro de entidades relacionales.


El SQL importa

En este laboratorio conviene tener habilitado:

JSON
"Microsoft.EntityFrameworkCore.Database.Command": "Information"

porque queremos observar el SQL real.

ExecuteUpdateAsync() ejecuta directamente la operación.

A diferencia de una consulta:

C#
IQueryable<T>

no tenemos una consulta pendiente sobre la que simplemente llamar a:

C#
ToQueryString()

Por eso el Output de EF es una evidencia especialmente útil para este laboratorio.


El laboratorio

La estructura es:

TEXT
EfCore10Labs
└── 04-ExecuteUpdateJson
    └── ExecuteUpdateJson.Api

Stack:

TEXT
.NET 10
EF Core 10.0.12
ASP.NET Core Web API
SQL Server

Endpoints principales:

TEXT
POST /api/blogs/seed
 
POST /api/blogs/reset
 
GET /api/blogs
 
POST /api/blogs/savechanges/increment-views
 
POST /api/blogs/executeupdate/set-views
 
POST /api/blogs/executeupdate/increment-views
 
POST /api/blogs/executeupdate/feature-category
 
POST /api/blogs/change-tracker-demo/{id}

El endpoint de reset existe únicamente para que las pruebas puedan repetirse desde el mismo estado inicial.


Comparación final

SaveChanges

TEXT
SELECT
↓
materialización
↓
Change Tracker
↓
modificación en memoria
↓
SaveChanges
↓
UPDATE

ExecuteUpdate

TEXT
LINQ filter
↓
ExecuteUpdate
↓
UPDATE

No necesitamos:

TEXT
materializar
trackear
SaveChanges

Conclusión

La novedad no es ExecuteUpdate.

Esa API existe desde EF Core 7.

Lo nuevo en EF Core 10 es que la frontera de ExecuteUpdate llega también al modelo documental dentro de bases relacionales.

Ahora podemos escribir:

C#
.SetProperty(
    blog => blog.Details.Views,
    100)

aunque Views esté dentro de un Complex Type almacenado como JSON.

También podemos expresar:

C#
.SetProperty(
    blog => blog.Details.Views,
    blog => blog.Details.Views + 1)

y delegar la actualización al motor.

Eso nos permite combinar:

TEXT
modelo C# fuertemente tipado
+
JSON dentro de SQL Server
+
actualizaciones masivas
+
sin materialización

Pero ExecuteUpdate no utiliza el Change Tracker, se ejecuta inmediatamente y no reemplaza a SaveChanges.

La decisión depende del tipo de operación.

Si necesitamos trabajar con objetos y cambios de dominio:

TEXT
SaveChanges

Si sabemos exactamente qué filas y propiedades queremos modificar:

TEXT
ExecuteUpdate

En el próximo artículo vamos a continuar sobre esta API para estudiar otra mejora de EF Core 10:

TEXT
Dynamic ExecuteUpdate.