Skip to content

Commit 95c57ef

Browse files
authored
Merge pull request #58 from EFNext/feat/hot-reload-tweaks
Add hot reload support for EF Core and MongoDB integrations
2 parents 1becb60 + af62745 commit 95c57ef

6 files changed

Lines changed: 254 additions & 0 deletions

File tree

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const sidebar: DefaultTheme.Sidebar = {
7777
{ text: 'Block-Bodied Members', link: '/advanced/block-bodied-members' },
7878
{ text: 'Custom Transformers', link: '/advanced/custom-transformers' },
7979
{ text: 'Testing Strategy', link: '/advanced/testing-strategy' },
80+
{ text: 'Hot Reload', link: '/advanced/hot-reload' },
8081
{ text: 'Limitations', link: '/advanced/limitations' },
8182
]
8283
}

docs/advanced/hot-reload.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Hot Reload
2+
3+
ExpressiveSharp supports .NET hot reload. When you edit the body of an `[Expressive]` member during a `dotnet watch` (or IDE hot-reload) session, the next query execution uses the new expression — no manual cache clear, app restart, or rebuild required.
4+
5+
## How It Works
6+
7+
Three things happen on a hot-reload event:
8+
9+
1. **Source generator re-runs incrementally.** Roslyn re-emits the per-assembly `ExpressionRegistry` so the factory methods produce lambdas built from the new IL.
10+
2. **The runtime invokes ExpressiveSharp's `[MetadataUpdateHandler]`.** This handler calls `ResetMap()` on each affected assembly's generated registry (rebuilding the `MethodHandle → LambdaExpression` dictionary) and clears the resolver and replacer caches.
11+
3. **The next query expands fresh.** EF Core (and MongoDB) integrations call `ExpandExpressives()` on every query execution, so the new expression body is woven in immediately.
12+
13+
EF Core's compiled-query cache keys off the post-expansion tree shape. A structural change to an `[Expressive]` body produces a different cache key, so a fresh SQL compilation happens automatically.
14+
15+
## Caveats
16+
17+
::: warning `EF.CompileQuery` results are snapshotted
18+
`EF.CompileQuery` invokes expansion **once**, at compile time, and returns a delegate. Hot-reloading an `[Expressive]` member after the delegate is built does **not** retroactively update it. To pick up the new body, recreate the compiled query.
19+
:::
20+
21+
::: warning Manually captured expression trees are frozen
22+
If you call `.ExpandExpressives()` yourself and store the result in a field or static, the tree is captured at that moment. Hot reload does not rewrite already-materialized trees held in user memory. Prefer the per-execution path (`AsExpressive()` + LINQ, or the `ExpressiveQueryCompiler` decorator wired by `UseExpressives()`).
23+
:::
24+
25+
::: tip Constant-only edits behave like normal EF Core parameterization
26+
Changing `p.Status == 1` to `p.Status == 2` inside an `[Expressive]` member reuses the same compiled SQL with a different parameter — exactly the same behaviour as inline literals. This is the EF Core constant-parameterization rule, not an ExpressiveSharp limitation.
27+
:::
28+
29+
::: info Rude edits still need a restart
30+
Signature changes, member removal, attribute edits, and other [rude edits](https://learn.microsoft.com/en-us/aspnet/core/test/hot-reload) require an app restart — same as any other `dotnet watch` rude edit. Body-only changes are the supported case.
31+
:::
32+
33+
## Verifying It Works
34+
35+
1. Start your app under `dotnet watch run` with EF Core SQL logging enabled (`LogTo(Console.WriteLine, LogLevel.Information)`).
36+
2. Run a query that uses an `[Expressive]` member:
37+
38+
```csharp
39+
public sealed class Product
40+
{
41+
public int Quantity { get; set; }
42+
public int Price { get; set; }
43+
44+
[Expressive]
45+
public int Total => Quantity * Price;
46+
}
47+
48+
// ...
49+
var rows = ctx.Products.AsExpressive().Where(p => p.Total > 100).ToList();
50+
```
51+
52+
3. Observe the SQL: `WHERE [p].[Quantity] * [p].[Price] > @__p_0`.
53+
4. Edit `Total` to `Quantity * Price * 2`. Save.
54+
5. Trigger the same query path. The new SQL should reflect the updated computation.
55+
56+
If the SQL does not update, check that you are not (a) calling through `EF.CompileQuery`, or (b) reusing a captured `Expression` tree built before the edit.

docs/guide/integrations/ef-core.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ The built-in `RelationalExtensions` package (for window functions) uses this plu
214214
The `ExpressiveSharp.EntityFrameworkCore` package bundles Roslyn analyzers and code fixes from `ExpressiveSharp.EntityFrameworkCore.CodeFixers`. These provide compile-time diagnostics and IDE quick-fix actions for common issues like missing `[Expressive]` attributes.
215215
:::
216216

217+
## Hot Reload
218+
219+
Body edits to `[Expressive]` members flow through to EF Core automatically — `ExpressiveQueryCompiler` re-expands every query at execution time, and the new tree shape produces a fresh entry in EF Core's compiled-query cache. No restart, no manual cache clear.
220+
221+
::: warning
222+
`EF.CompileQuery(...)` snapshots expansion at compile time. Hot-reloading an `[Expressive]` member does not retroactively update an already-compiled query delegate — you have to recreate it.
223+
:::
224+
225+
See [Hot Reload](../../advanced/hot-reload) for the full picture, including caveats around captured expression trees and rude edits.
226+
217227
## Next Steps
218228

219229
- [Window Functions](../window-functions) — SQL window functions via the RelationalExtensions package
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
using ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Infrastructure;
4+
using ExpressiveSharp.IntegrationTests.Scenarios.Store.Models;
5+
using ExpressiveSharp.Services;
6+
using Microsoft.EntityFrameworkCore;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
9+
namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite;
10+
11+
[TestClass]
12+
[DoNotParallelize]
13+
public class HotReloadTests : EFCoreTestBase
14+
{
15+
protected override IAsyncDisposable CreateContextHandle(out DbContext context)
16+
{
17+
var handle = TestContextFactories.CreateSqlite();
18+
context = handle.Context;
19+
return handle;
20+
}
21+
22+
[TestInitialize]
23+
public Task SeedHotReloadData() => Context.SeedStoreAsync();
24+
25+
[TestMethod]
26+
public async Task HotReload_NewExpressiveBody_FlowsThroughToSqlAndResults()
27+
{
28+
var baselineSql = Context.Set<Order>().Where(o => o.Total > 200).ToQueryString();
29+
var baselineTotals = await Context.Set<Order>().Select(o => o.Total).OrderBy(t => t).ToListAsync();
30+
CollectionAssert.AreEqual(new[] { 30.0, 240.0, 250.0, 1500.0 }, baselineTotals);
31+
32+
var (registryType, mapField, totalKey) = HotReloadRegistry.Locate(typeof(Order), nameof(Order.Total));
33+
var map = (IDictionary<nint, LambdaExpression>)mapField.GetValue(null)!;
34+
Expression<Func<Order, double>> reloaded = o => o.Price * o.Quantity * 2;
35+
36+
try
37+
{
38+
map[totalKey] = reloaded;
39+
HotReloadRegistry.ClearResolverCaches();
40+
41+
var reloadedSql = Context.Set<Order>().Where(o => o.Total > 200).ToQueryString();
42+
Assert.AreNotEqual(baselineSql, reloadedSql,
43+
"ExpressiveQueryCompiler did not pick up the reloaded body — SQL is unchanged.");
44+
45+
var reloadedTotals = await Context.Set<Order>().Select(o => o.Total).OrderBy(t => t).ToListAsync();
46+
CollectionAssert.AreEqual(new[] { 60.0, 480.0, 500.0, 3000.0 }, reloadedTotals);
47+
}
48+
finally
49+
{
50+
HotReloadRegistry.Reset(registryType);
51+
HotReloadRegistry.ClearResolverCaches();
52+
}
53+
}
54+
}
55+
56+
internal static class HotReloadRegistry
57+
{
58+
public static (Type RegistryType, FieldInfo MapField, nint MemberKey) Locate(Type declaringType, string propertyName)
59+
{
60+
var registryType = declaringType.Assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry")
61+
?? throw new InvalidOperationException(
62+
$"Generated ExpressionRegistry not found in {declaringType.Assembly.GetName().Name}.");
63+
var mapField = registryType.GetField("_map", BindingFlags.Static | BindingFlags.NonPublic)
64+
?? throw new InvalidOperationException("ExpressionRegistry._map not found.");
65+
var key = declaringType.GetProperty(propertyName)!.GetMethod!.MethodHandle.Value;
66+
return (registryType, mapField, key);
67+
}
68+
69+
public static void Reset(Type registryType)
70+
{
71+
var reset = registryType.GetMethod("ResetMap", BindingFlags.Static | BindingFlags.NonPublic)!;
72+
reset.Invoke(null, null);
73+
}
74+
75+
public static void ClearResolverCaches()
76+
{
77+
var method = typeof(ExpressiveResolver).GetMethod(
78+
"ClearCachesForMetadataUpdate",
79+
BindingFlags.Static | BindingFlags.NonPublic)!;
80+
method.Invoke(null, null);
81+
}
82+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
using ExpressiveSharp.IntegrationTests.Scenarios.Store.Models;
4+
using ExpressiveSharp.MongoDB.Extensions;
5+
using ExpressiveSharp.MongoDB.IntegrationTests.Infrastructure;
6+
using ExpressiveSharp.Services;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
using MongoDB.Driver;
9+
using MongoDB.Driver.Linq;
10+
11+
namespace ExpressiveSharp.MongoDB.IntegrationTests.Tests;
12+
13+
[TestClass]
14+
[DoNotParallelize]
15+
public class HotReloadTests : MongoTestBase
16+
{
17+
[TestMethod]
18+
public async Task HotReload_NewExpressiveBody_FlowsThroughToMongoQueryProvider()
19+
{
20+
var baselineTotals = await Orders.AsExpressive()
21+
.Select(o => o.Total)
22+
.ToListAsync();
23+
CollectionAssert.AreEquivalent(new[] { 240.0, 1500.0, 30.0, 250.0 }, baselineTotals);
24+
25+
var (registryType, mapField, totalKey) = HotReloadRegistry.Locate(typeof(Order), nameof(Order.Total));
26+
var map = (IDictionary<nint, LambdaExpression>)mapField.GetValue(null)!;
27+
Expression<Func<Order, double>> reloaded = o => o.Price * o.Quantity * 2;
28+
29+
try
30+
{
31+
map[totalKey] = reloaded;
32+
HotReloadRegistry.ClearResolverCaches();
33+
34+
var reloadedTotals = await Orders.AsExpressive()
35+
.Select(o => o.Total)
36+
.ToListAsync();
37+
CollectionAssert.AreEquivalent(new[] { 480.0, 3000.0, 60.0, 500.0 }, reloadedTotals);
38+
}
39+
finally
40+
{
41+
HotReloadRegistry.Reset(registryType);
42+
HotReloadRegistry.ClearResolverCaches();
43+
}
44+
}
45+
}
46+
47+
internal static class HotReloadRegistry
48+
{
49+
public static (Type RegistryType, FieldInfo MapField, nint MemberKey) Locate(Type declaringType, string propertyName)
50+
{
51+
var registryType = declaringType.Assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry")
52+
?? throw new InvalidOperationException(
53+
$"Generated ExpressionRegistry not found in {declaringType.Assembly.GetName().Name}.");
54+
var mapField = registryType.GetField("_map", BindingFlags.Static | BindingFlags.NonPublic)
55+
?? throw new InvalidOperationException("ExpressionRegistry._map not found.");
56+
var key = declaringType.GetProperty(propertyName)!.GetMethod!.MethodHandle.Value;
57+
return (registryType, mapField, key);
58+
}
59+
60+
public static void Reset(Type registryType)
61+
{
62+
var reset = registryType.GetMethod("ResetMap", BindingFlags.Static | BindingFlags.NonPublic)!;
63+
reset.Invoke(null, null);
64+
}
65+
66+
public static void ClearResolverCaches()
67+
{
68+
var method = typeof(ExpressiveResolver).GetMethod(
69+
"ClearCachesForMetadataUpdate",
70+
BindingFlags.Static | BindingFlags.NonPublic)!;
71+
method.Invoke(null, null);
72+
}
73+
}

tests/ExpressiveSharp.Tests/Services/ExpressiveHotReloadHandlerTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Linq.Expressions;
12
using System.Reflection;
23
using System.Reflection.Metadata;
34
using ExpressiveSharp.Services;
@@ -92,4 +93,35 @@ public void Assembly_RegistersExpressiveHotReloadHandler()
9293
Assert.IsTrue(attributes.Any(a => a.HandlerType == typeof(ExpressiveHotReloadHandler)),
9394
"MetadataUpdateHandlerAttribute for ExpressiveHotReloadHandler not found on ExpressiveSharp assembly.");
9495
}
96+
97+
[TestMethod]
98+
public void ClearCache_RebuildsGeneratedRegistryMap()
99+
{
100+
var registryType = typeof(Product).Assembly.GetType("ExpressiveSharp.Generated.ExpressionRegistry");
101+
Assert.IsNotNull(registryType, "Generated ExpressionRegistry not found in test assembly.");
102+
103+
var mapField = registryType!.GetField("_map", BindingFlags.Static | BindingFlags.NonPublic);
104+
Assert.IsNotNull(mapField, "ExpressionRegistry._map field not found.");
105+
106+
var totalProperty = typeof(Product).GetProperty(nameof(Product.Total))!;
107+
var resolver = new ExpressiveResolver();
108+
109+
var initial = resolver.FindGeneratedExpression(totalProperty);
110+
Assert.IsNotNull(initial);
111+
Assert.AreEqual(ExpressionType.Multiply, initial.Body.NodeType);
112+
113+
var mapBefore = mapField!.GetValue(null);
114+
Assert.IsNotNull(mapBefore);
115+
116+
ExpressiveHotReloadHandler.ClearCache([typeof(Product)]);
117+
118+
var mapAfter = mapField.GetValue(null);
119+
Assert.IsNotNull(mapAfter);
120+
Assert.IsFalse(ReferenceEquals(mapBefore, mapAfter),
121+
"ExpressionRegistry._map was not rebuilt — ResetMap was not invoked by the hot-reload handler.");
122+
123+
var rebuilt = resolver.FindGeneratedExpression(totalProperty);
124+
Assert.IsNotNull(rebuilt);
125+
Assert.AreEqual(initial.ToString(), rebuilt.ToString());
126+
}
95127
}

0 commit comments

Comments
 (0)