diff --git a/docs/documents/querying/compiled-queries.md b/docs/documents/querying/compiled-queries.md
index c4e446b819..84c6d59e68 100644
--- a/docs/documents/querying/compiled-queries.md
+++ b/docs/documents/querying/compiled-queries.md
@@ -778,3 +778,89 @@ public async Task use_as_batch()
```
snippet source | anchor
+
+## Query Plan Cache for Ad Hoc Linq Queries
+
+::: info
+Not to be confused with the `IQueryPlan` "Specification" pattern described above, or with the Postgres `EXPLAIN`
+output returned by [`ExplainAsync()`](/diagnostics#previewing-the-postgresql-query-plan). This is a distinct, opt-in
+performance feature for ordinary `IQueryable` Linq queries.
+:::
+
+`ICompiledQuery` gives you the best raw performance by completely bypassing Linq expression parsing, but it comes at
+the cost of a fixed query shape -- every compiled query class represents exactly one filter/sort/paging combination.
+That is a poor fit for endpoints with _optional_ or _conditional_ filters, where the same handler builds up a
+different `Where()` chain depending on which query string parameters were supplied. Historically, every distinct
+combination of filters on that kind of endpoint paid the full cost of Linq expression parsing and SQL generation on
+every single call, because each combination is technically a different Linq expression tree.
+
+The query plan cache closes that gap. It is an opt-in, bounded cache that recognizes when two Linq queries share the
+same _structural shape_ -- the same sequence of `Where()`, `OrderBy()`/`OrderByDescending()`, `ThenBy()`/
+`ThenByDescending()`, `Skip()`, `Take()`, and `Select()` calls -- even though the actual filter values differ. On a
+cache hit, Marten skips Linq parsing and SQL generation entirely and just substitutes the new filter values into the
+previously-built database command.
+
+### Enabling the cache
+
+The cache is disabled by default. Turn it on for the whole `DocumentStore` with either of these equivalent options:
+
+```cs
+var opts = new StoreOptions();
+
+// Full control over the maximum number of cached shapes
+opts.Linq.QueryPlanCache = QueryPlanCache.PerShape(maxEntries: 1024);
+
+// Or the shorthand version, also with a default of 1024 entries
+opts.Linq.EnableQueryPlanCaching();
+```
+
+Once enabled, opt individual Linq queries into the cache by passing `QueryPlanCaching.Cached` to `ToListAsync()`:
+
+```cs
+IReadOnlyList lines = await session
+ .Query()
+ .Where(x => x.CustomerId == customerId)
+ .Where(x => status.HasValue ? x.Status == status.Value : true)
+ .OrderBy(x => x.CreatedAt)
+ .ToListAsync(cancellation, QueryPlanCaching.Cached);
+```
+
+Every call to that code with a different `customerId` and/or `status` reuses the same cached plan the second and
+subsequent times it's called, as long as which `Where()` clauses are present (not their values) stays the same.
+
+### How shape keys work
+
+Behind the scenes, Marten walks the Linq expression tree for the query and builds a structural "shape key":
+
+* Method calls, operators, and member names contribute to the key by their _name_ and _position_ in the tree, never
+ by value.
+* Anything that looks like a captured local variable, method parameter, or literal constant -- i.e., the actual
+ filter values -- is treated as a positional "slot" rather than part of the shape. Two queries with the identical
+ Linq structure but different slot values always produce the same shape key.
+* The key is scoped by both the source document type and the requested result type, so two structurally identical
+ queries against different document types never collide.
+
+Only a deliberately narrow, safe subset of Linq is recognized as cacheable: `Where`, `OrderBy`, `OrderByDescending`,
+`ThenBy`, `ThenByDescending`, `Skip`, `Take`, and `Select`, built from simple comparisons and member access. Anything
+outside that -- `Include()`, `Stats()`, raw/custom SQL, `StartsWith()`/`Contains()`, `GroupBy()`, `SelectMany()`, and
+so on -- is automatically recognized as unsupported for caching. Unsupported queries are not an error; they simply
+execute through Marten's normal, always-correct Linq pipeline exactly like they do today, with no caching applied.
+
+::: tip
+Correctness always wins over caching. If Marten can't fully account for every parameter that will end up in the
+generated SQL command for a shape -- for example, a conjoined multi-tenancy `tenant_id` parameter, which comes from
+the session rather than from the query expression -- that shape is simply never cached rather than risk ever
+replaying a stale or unrelated value.
+:::
+
+### Bounded cache size
+
+`QueryPlanCache.PerShape(maxEntries)` bounds the number of distinct shapes that will be cached at once. Once that
+limit is reached, the oldest cached shapes are evicted first to make room for new ones, so the cache can't grow
+without bound even for endpoints that produce a very large number of distinct filter combinations.
+
+### Current scope
+
+As of this writing, the query plan cache only applies to the `ToListAsync(CancellationToken,
+QueryPlanCaching)` overload. Other terminal operators such as `CountAsync()`, `AnyAsync()`, and streaming queries do
+not yet participate in the cache.
diff --git a/src/LinqTests/query_plan_cache_Tests.cs b/src/LinqTests/query_plan_cache_Tests.cs
new file mode 100644
index 0000000000..0410d0ca52
--- /dev/null
+++ b/src/LinqTests/query_plan_cache_Tests.cs
@@ -0,0 +1,155 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Marten;
+using Marten.Linq;
+using Marten.Linq.Caching;
+using Marten.Testing.Documents;
+using Marten.Testing.Harness;
+using Shouldly;
+
+namespace LinqTests;
+
+public class query_plan_cache_Tests: OneOffConfigurationsContext
+{
+ [Fact]
+ public void same_shape_different_values_produces_same_key()
+ {
+ int number1 = 1;
+ int number2 = 2;
+
+ var (expr1, _) = whereExpression(number1);
+ var (expr2, _) = whereExpression(number2);
+
+ var shape1 = ExpressionShapeVisitor.Analyze(expr1);
+ var shape2 = ExpressionShapeVisitor.Analyze(expr2);
+
+ shape1.IsSupported.ShouldBeTrue();
+ shape2.IsSupported.ShouldBeTrue();
+
+ shape1.BuildKey(typeof(Target), typeof(Target))
+ .ShouldBe(shape2.BuildKey(typeof(Target), typeof(Target)));
+ }
+
+ [Fact]
+ public void different_shapes_produce_different_keys()
+ {
+ var (whereExpr, _) = whereExpression(1);
+ var (skipTakeExpr, _) = skipTakeExpression(1, 2);
+
+ var shape1 = ExpressionShapeVisitor.Analyze(whereExpr);
+ var shape2 = ExpressionShapeVisitor.Analyze(skipTakeExpr);
+
+ shape1.IsSupported.ShouldBeTrue();
+ shape2.IsSupported.ShouldBeTrue();
+
+ shape1.BuildKey(typeof(Target), typeof(Target))
+ .ShouldNotBe(shape2.BuildKey(typeof(Target), typeof(Target)));
+ }
+
+ [Fact]
+ public void different_filter_combinations_produce_different_keys()
+ {
+ var (whereOnlyExpr, _) = whereExpression(1);
+ var (whereAndOrderExpr, _) = whereAndOrderExpression(1);
+
+ var shape1 = ExpressionShapeVisitor.Analyze(whereOnlyExpr);
+ var shape2 = ExpressionShapeVisitor.Analyze(whereAndOrderExpr);
+
+ shape1.BuildKey(typeof(Target), typeof(Target))
+ .ShouldNotBe(shape2.BuildKey(typeof(Target), typeof(Target)));
+ }
+
+ [Fact]
+ public void unsupported_shapes_are_flagged()
+ {
+ IQueryable queryable = new Target[0].AsQueryable();
+ var expr = queryable.Where(x => x.String.StartsWith("A")).Expression;
+
+ var shape = ExpressionShapeVisitor.Analyze(expr);
+
+ shape.IsSupported.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task cached_plan_produces_same_results_as_uncached()
+ {
+ StoreOptions(_ => _.Linq.EnableQueryPlanCaching());
+
+ using var session = theStore.LightweightSession();
+ session.Store(new Target { Number = 1, String = "one" });
+ session.Store(new Target { Number = 2, String = "two" });
+ session.Store(new Target { Number = 3, String = "three" });
+ await session.SaveChangesAsync();
+
+ var uncached = await session.Query().Where(x => x.Number == 2).ToListAsync();
+
+ var cached = await session.Query().Where(x => x.Number == 2)
+ .ToListAsync(default, QueryPlanCaching.Cached);
+
+ cached.Count.ShouldBe(uncached.Count);
+ cached.Single().Number.ShouldBe(2);
+
+ // Different filter value, same shape -- should hit the cache on the second call.
+ var cached2 = await session.Query().Where(x => x.Number == 3)
+ .ToListAsync(default, QueryPlanCaching.Cached);
+
+ cached2.Single().Number.ShouldBe(3);
+
+ theStore.Options.Linq.QueryPlanCache.Count.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task cache_respects_max_entries()
+ {
+ StoreOptions(_ => _.Linq.QueryPlanCache = QueryPlanCache.PerShape(maxEntries: 1));
+
+ using var session = theStore.LightweightSession();
+ session.Store(new Target { Number = 1 });
+ await session.SaveChangesAsync();
+
+ await session.Query().Where(x => x.Number == 1)
+ .ToListAsync(default, QueryPlanCaching.Cached);
+ await session.Query().Where(x => x.String == "abc")
+ .ToListAsync(default, QueryPlanCaching.Cached);
+
+ theStore.Options.Linq.QueryPlanCache.Count.ShouldBeLessThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task opt_in_configuration_is_required()
+ {
+ // No StoreOptions() call enabling the cache -- QueryPlanCache.Disabled is the default.
+ using var session = theStore.LightweightSession();
+ session.Store(new Target { Number = 1 });
+ await session.SaveChangesAsync();
+
+ theStore.Options.Linq.QueryPlanCache.Enabled.ShouldBeFalse();
+
+ var results = await session.Query().Where(x => x.Number == 1)
+ .ToListAsync(default, QueryPlanCaching.Cached);
+
+ results.Single().Number.ShouldBe(1);
+ theStore.Options.Linq.QueryPlanCache.Count.ShouldBe(0);
+ }
+
+ private static (System.Linq.Expressions.Expression, int) whereExpression(int number)
+ {
+ IQueryable queryable = new Target[0].AsQueryable();
+ var expr = queryable.Where(x => x.Number == number).Expression;
+ return (expr, number);
+ }
+
+ private static (System.Linq.Expressions.Expression, int) whereAndOrderExpression(int number)
+ {
+ IQueryable queryable = new Target[0].AsQueryable();
+ var expr = queryable.Where(x => x.Number == number).OrderBy(x => x.Number).Expression;
+ return (expr, number);
+ }
+
+ private static (System.Linq.Expressions.Expression, int) skipTakeExpression(int skip, int take)
+ {
+ IQueryable queryable = new Target[0].AsQueryable();
+ var expr = queryable.Skip(skip).Take(take).Expression;
+ return (expr, skip);
+ }
+}
diff --git a/src/Marten/Linq/Caching/CachedLinqPlan.cs b/src/Marten/Linq/Caching/CachedLinqPlan.cs
new file mode 100644
index 0000000000..3c8a805f18
--- /dev/null
+++ b/src/Marten/Linq/Caching/CachedLinqPlan.cs
@@ -0,0 +1,31 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using Marten.Linq.QueryHandlers;
+using Npgsql;
+
+namespace Marten.Linq.Caching;
+
+///
+/// A single compiled-and-cached LINQ plan for one structural query shape: the query
+/// handler used to read results (safe to reuse across calls -- selectors only depend
+/// on the shape, never on parameter values), a template with
+/// unique sentinel parameter values recorded once, and the mapping from each parameter
+/// back to the slot (position in ) that
+/// supplied it.
+///
+internal sealed class CachedLinqPlan
+{
+ public required IQueryHandler Handler { get; init; }
+ public required NpgsqlBatch TemplateBatch { get; init; }
+ public required IReadOnlyList Bindings { get; init; }
+ public required IReadOnlyList DocumentTypes { get; init; }
+}
+
+///
+/// Records that the parameter at within the batch command
+/// at should be rebound, on every cache hit, from the
+/// current value of the expression slot at .
+///
+internal readonly record struct SlotBinding(int CommandIndex, int ParameterIndex, int SlotIndex);
+
diff --git a/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs b/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs
new file mode 100644
index 0000000000..5f9e703729
--- /dev/null
+++ b/src/Marten/Linq/Caching/ExpressionShapeVisitor.cs
@@ -0,0 +1,245 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Security.Cryptography;
+using System.Text;
+using Marten.Linq.Parsing;
+
+namespace Marten.Linq.Caching;
+
+///
+/// Walks a LINQ expression tree (the Where/OrderBy/Skip/Take/Select chain built by
+/// ) to compute a structural "shape" key that is
+/// stable across calls with the same query shape but different captured (closure)
+/// values, and collects the ordered list of value-bearing "slot" sub-expressions --
+/// the leaves that vary between calls (constants, and closure-captured member
+/// accesses).
+///
+///
+/// This is deliberately conservative. Anything that isn't one of a small allow-listed
+/// set of node types (or a non-allow-listed method call such as StartsWith(),
+/// Include(), Stats(), GroupBy(), SelectMany(), custom SQL,
+/// etc.) flips to false, which means the query plan
+/// cache () will never attempt to cache it. Correctness
+/// always wins over caching a shape we can't fully reason about.
+///
+internal sealed class ExpressionShapeVisitor: ExpressionVisitor
+{
+ private static readonly HashSet AllowedQueryableMethods = new()
+ {
+ nameof(Queryable.Where),
+ nameof(Queryable.OrderBy),
+ nameof(Queryable.OrderByDescending),
+ nameof(Queryable.ThenBy),
+ nameof(Queryable.ThenByDescending),
+ nameof(Queryable.Skip),
+ nameof(Queryable.Take),
+ nameof(Queryable.Select)
+ };
+
+ private readonly StringBuilder _shape = new();
+ private readonly List _slots = new();
+ private bool _supported = true;
+
+ private ExpressionShapeVisitor()
+ {
+ }
+
+ ///
+ /// False if the expression uses anything outside of the narrow MVP scope (simple
+ /// Where comparisons, OrderBy/ThenBy, Skip/Take, Select of members) that the plan
+ /// cache doesn't know how to safely replay.
+ ///
+ public bool IsSupported => _supported;
+
+ ///
+ /// The ordered list of sub-expressions -- closures and literal constants -- whose
+ /// runtime values may differ between calls sharing this shape.
+ ///
+ public IReadOnlyList Slots => _slots;
+
+ public static ExpressionShapeVisitor Analyze(Expression expression)
+ {
+ var visitor = new ExpressionShapeVisitor();
+
+ try
+ {
+ visitor.Visit(expression);
+ }
+ catch (Exception)
+ {
+ // Any failure while walking the tree means we don't understand this shape
+ // well enough to cache it. Fall back to the always-correct, uncached path.
+ visitor._supported = false;
+ }
+
+ return visitor;
+ }
+
+ ///
+ /// Builds a stable cache key for this shape, scoped to the source document type and
+ /// the requested result type (so two structurally identical shapes over different
+ /// document types never collide).
+ ///
+ public string BuildKey(Type sourceType, Type resultType)
+ {
+ var text = sourceType.FullName + "|" + resultType.FullName + "|" + _shape;
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
+ return Convert.ToHexString(bytes);
+ }
+
+ public override Expression? Visit(Expression? node)
+ {
+ if (node == null)
+ {
+ return null;
+ }
+
+ if (!_supported)
+ {
+ return node;
+ }
+
+ switch (node.NodeType)
+ {
+ case ExpressionType.Call:
+ case ExpressionType.Lambda:
+ case ExpressionType.Equal:
+ case ExpressionType.NotEqual:
+ case ExpressionType.GreaterThan:
+ case ExpressionType.GreaterThanOrEqual:
+ case ExpressionType.LessThan:
+ case ExpressionType.LessThanOrEqual:
+ case ExpressionType.AndAlso:
+ case ExpressionType.OrElse:
+ case ExpressionType.And:
+ case ExpressionType.Or:
+ case ExpressionType.Convert:
+ case ExpressionType.ConvertChecked:
+ case ExpressionType.Not:
+ case ExpressionType.Negate:
+ case ExpressionType.Quote:
+ case ExpressionType.MemberAccess:
+ case ExpressionType.Constant:
+ case ExpressionType.Parameter:
+ case ExpressionType.New:
+ return base.Visit(node);
+ default:
+ // Anything else (conditional, indexers, member init, invoke, etc.) is out
+ // of scope for the MVP cache.
+ _supported = false;
+ return node;
+ }
+ }
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ if ((node.Method.DeclaringType == typeof(Queryable) || node.Method.DeclaringType == typeof(Enumerable))
+ && AllowedQueryableMethods.Contains(node.Method.Name))
+ {
+ _shape.Append("M(").Append(node.Method.Name);
+ foreach (var t in node.Method.GetGenericArguments())
+ {
+ _shape.Append('<').Append(t.FullName).Append('>');
+ }
+
+ _shape.Append(':');
+ foreach (var argument in node.Arguments)
+ {
+ Visit(argument);
+ _shape.Append(',');
+ }
+
+ _shape.Append(')');
+ return node;
+ }
+
+ // Anything else -- Contains(), StartsWith(), Include(), Stats(), custom SQL,
+ // GroupBy, SelectMany, etc. -- is out of scope for the MVP cache.
+ _supported = false;
+ return node;
+ }
+
+ protected override Expression VisitLambda(Expression node)
+ {
+ _shape.Append("λ(");
+ Visit(node.Body);
+ _shape.Append(')');
+ return node;
+ }
+
+ protected override Expression VisitBinary(BinaryExpression node)
+ {
+ _shape.Append(node.NodeType).Append('(');
+ Visit(node.Left);
+ _shape.Append(',');
+ Visit(node.Right);
+ _shape.Append(')');
+ return node;
+ }
+
+ protected override Expression VisitUnary(UnaryExpression node)
+ {
+ _shape.Append(node.NodeType).Append('(');
+ Visit(node.Operand);
+ _shape.Append(')');
+ return node;
+ }
+
+ protected override Expression VisitMember(MemberExpression node)
+ {
+ if (node.IsCompilableExpression())
+ {
+ RecordSlot(node);
+ return node;
+ }
+
+ _shape.Append("Mem(").Append(node.Member.DeclaringType?.FullName).Append('.').Append(node.Member.Name)
+ .Append(':');
+ Visit(node.Expression);
+ _shape.Append(')');
+ return node;
+ }
+
+ protected override Expression VisitConstant(ConstantExpression node)
+ {
+ if (node.Value is IQueryable)
+ {
+ // The root document collection anchor -- structurally stable across every
+ // call with this shape, not a value slot.
+ _shape.Append("Root(").Append(node.Type.FullName).Append(')');
+ return node;
+ }
+
+ RecordSlot(node);
+ return node;
+ }
+
+ protected override Expression VisitParameter(ParameterExpression node)
+ {
+ _shape.Append("Param(").Append(node.Type.FullName).Append(')');
+ return node;
+ }
+
+ protected override Expression VisitNew(NewExpression node)
+ {
+ _shape.Append("New(").Append(node.Type.FullName).Append(':');
+ foreach (var argument in node.Arguments)
+ {
+ Visit(argument);
+ _shape.Append(',');
+ }
+
+ _shape.Append(')');
+ return node;
+ }
+
+ private void RecordSlot(Expression node)
+ {
+ _shape.Append("Slot").Append(_slots.Count).Append(':').Append(node.Type.FullName);
+ _slots.Add(node);
+ }
+}
+
diff --git a/src/Marten/Linq/Caching/LinqPlanRecorder.cs b/src/Marten/Linq/Caching/LinqPlanRecorder.cs
new file mode 100644
index 0000000000..00e379e56b
--- /dev/null
+++ b/src/Marten/Linq/Caching/LinqPlanRecorder.cs
@@ -0,0 +1,196 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using Marten.Internal;
+using Marten.Internal.CompiledQueries;
+using Marten.Internal.Sessions;
+using Marten.Linq.Parsing;
+using Marten.Linq.QueryHandlers;
+using Marten.Util;
+using Npgsql;
+
+namespace Marten.Linq.Caching;
+
+///
+/// Builds a for a query shape on a cache miss.
+///
+///
+/// The approach mirrors how Marten already compiles ICompiledQuery plans
+/// (): a "template" expression is built by replacing
+/// every value slot with a distinct, never-otherwise-occurring sentinel value (reusing
+/// the same /
+/// machinery). That template is compiled through the normal LINQ pipeline exactly once,
+/// and the resulting command's parameters are matched back to slots purely by sentinel
+/// value identity -- no reflection into Weasel's (immutable) CommandParameter or
+/// Marten's internal filter types is required.
+///
+/// If any command parameter can't be confidently attributed to a slot (for example
+/// a conjoined multi-tenancy tenant id, which is a per-session value rather than a
+/// value captured by the query expression), the whole shape is rejected -- it is
+/// never cached. This guarantees a cache hit can never replay a stale or foreign
+/// parameter value.
+///
+///
+internal static class LinqPlanRecorder
+{
+ public static CachedLinqPlan? TryBuild(
+ MartenLinqQueryProvider provider,
+ QuerySession session,
+ Expression realExpression,
+ ExpressionShapeVisitor shape,
+ Func> buildHandler)
+ {
+ if (!shape.IsSupported || shape.Slots.Count == 0)
+ {
+ return null;
+ }
+
+ var sentinelValues = new object?[shape.Slots.Count];
+ var valueSource = new UniqueValueSource();
+
+ for (var i = 0; i < shape.Slots.Count; i++)
+ {
+ var slotType = shape.Slots[i].Type;
+ var clrType = Nullable.GetUnderlyingType(slotType) ?? slotType;
+
+ if (!QueryCompiler.Finders.Any(f => f.Matches(clrType)))
+ {
+ // No known way to manufacture a unique sentinel value for this CLR type
+ // (e.g. bool) -- don't cache.
+ return null;
+ }
+
+ try
+ {
+ sentinelValues[i] = valueSource.GetValue(clrType);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ Expression templateExpression;
+ try
+ {
+ templateExpression = new SlotReplacingVisitor(shape.Slots, sentinelValues).Visit(realExpression)!;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ LinqQueryParser parser;
+ IQueryHandler handler;
+ Type[] documentTypes;
+ NpgsqlBatch templateBatch;
+
+ try
+ {
+ parser = new LinqQueryParser(provider, session, templateExpression);
+ handler = buildHandler(parser);
+ documentTypes = parser.DocumentTypes().ToArray();
+ templateBatch = ((IMartenSession)session).BuildCommand(handler);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ var bindings = new List();
+ var usedSlots = new bool[sentinelValues.Length];
+
+ for (var ci = 0; ci < templateBatch.BatchCommands.Count; ci++)
+ {
+ var command = templateBatch.BatchCommands[ci];
+ for (var pi = 0; pi < command.Parameters.Count; pi++)
+ {
+ var value = command.Parameters[pi].Value;
+ var matchedSlot = -1;
+
+ for (var si = 0; si < sentinelValues.Length; si++)
+ {
+ if (usedSlots[si])
+ {
+ continue;
+ }
+
+ if (Equals(value, sentinelValues[si]))
+ {
+ matchedSlot = si;
+ break;
+ }
+ }
+
+ if (matchedSlot < 0)
+ {
+ // A parameter we can't attribute to one of our captured slots (tenant
+ // id, other session-scoped values, etc.) -- refuse to cache rather than
+ // risk replaying a stale or foreign value on a future hit.
+ return null;
+ }
+
+ usedSlots[matchedSlot] = true;
+ bindings.Add(new SlotBinding(ci, pi, matchedSlot));
+ }
+ }
+
+ return new CachedLinqPlan
+ {
+ Handler = handler,
+ TemplateBatch = templateBatch,
+ Bindings = bindings,
+ DocumentTypes = documentTypes
+ };
+ }
+
+ ///
+ /// Builds a fresh from a cached plan's template, replacing
+ /// each parameter's value with the current slot value supplied by the caller. This
+ /// is the whole point of the cache: no LINQ parsing, no SQL generation -- just a
+ /// cheap clone-and-rebind.
+ ///
+ public static NpgsqlBatch RebindValues(CachedLinqPlan plan, IReadOnlyList