Skip to content

Commit 55dc86b

Browse files
vbilopavclaude
andcommitted
bump 3.13.0
- New: WrapInTransaction option (PgBouncer transaction-mode safety) - New: BeforeRoutineCommands option (parameterized SQL run before routine call; multi-tenant search_path pattern) - Fix: 400 Bad Request responses now log at Warning level (previously silent) - Docker: native AOT images rebased on Ubuntu 26.04 LTS - NuGet upgrades for main library and client app Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 714ef32 commit 55dc86b

31 files changed

Lines changed: 986 additions & 63 deletions

.github/workflows/build-test-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
workflow_dispatch:
99

1010
env:
11-
RELEASE_VERSION: v3.12.0
11+
RELEASE_VERSION: v3.13.0
1212

1313
# Add workflow-level permissions
1414
permissions:

NpgsqlRest/Consts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public static class Consts
3333
public const string EmptyArray = "[]";
3434
public const string EmptyObj = "{}";
3535
public const string SetContext = "select set_config($1,$2,false)";
36+
public const string SetContextLocal = "select set_config($1,$2,true)";
3637

3738
// Pre-computed UTF8 bytes for JSON structure characters used in rendering hot paths.
3839
// Eliminates per-request Encoding.UTF8.GetBytes() calls for constant values.

NpgsqlRest/Enums.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,24 @@ public enum SseEventsScope
9292
/// All connected sessions regardless of authorization
9393
/// </summary>
9494
All
95+
}
96+
97+
/// <summary>
98+
/// Source of a value bound to a BeforeRoutineCommand parameter at request time.
99+
/// </summary>
100+
public enum BeforeRoutineCommandParameterSource
101+
{
102+
/// <summary>
103+
/// Value comes from a user claim. Parameter Name is the claim type to look up on HttpContext.User.
104+
/// Multi-value claims are joined the same way as ContextKeyClaimsMapping. Missing claims bind to NULL.
105+
/// </summary>
106+
Claim,
107+
/// <summary>
108+
/// Value comes from a request header. Parameter Name is the header name. Missing headers bind to NULL.
109+
/// </summary>
110+
RequestHeader,
111+
/// <summary>
112+
/// Value is the client IP address (uses the same resolution as IpAddressContextKey). Parameter Name is ignored.
113+
/// </summary>
114+
IpAddress
95115
}

NpgsqlRest/Log.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ public static partial class Log
272272
[LoggerMessage(Level = LogLevel.Warning, Message = "{description} has failed to set VALIDATION RULE by the comment annotation. Rule '{ruleName}' not found in ValidationOptions.Rules.")]
273273
public static partial void ValidationRuleNotFound(this ILogger logger, string description, string ruleName);
274274

275-
[LoggerMessage(Level = LogLevel.Debug, Message = "{endpoint} validation failed for parameter '{paramName}': {message}")]
275+
[LoggerMessage(Level = LogLevel.Warning, Message = "{endpoint} validation failed for parameter '{paramName}': {message}")]
276276
public static partial void ValidationFailed(this ILogger logger, string endpoint, string paramName, string message);
277277

278278
[LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set NESTED JSON FOR COMPOSITE TYPES by the comment annotation.")]

NpgsqlRest/NpgsqlRest.csproj

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
<GenerateDocumentationFile>true</GenerateDocumentationFile>
3030
<PackageReadmeFile>README.MD</PackageReadmeFile>
3131
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
32-
<Version>3.12.0</Version>
33-
<AssemblyVersion>3.12.0</AssemblyVersion>
34-
<FileVersion>3.12.0</FileVersion>
35-
<PackageVersion>3.12.0</PackageVersion>
32+
<Version>3.13.0</Version>
33+
<AssemblyVersion>3.13.0</AssemblyVersion>
34+
<FileVersion>3.13.0</FileVersion>
35+
<PackageVersion>3.13.0</PackageVersion>
3636
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
3737
</PropertyGroup>
3838

@@ -41,7 +41,7 @@
4141
</PropertyGroup>
4242

4343
<ItemGroup>
44-
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.201" PrivateAssets="All" />
44+
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203" PrivateAssets="All" />
4545
<PackageReference Include="Npgsql" Version="10.0.2" />
4646
<PackageReferenceFiles Include="bin\$(Configuration)\$(AssemblyName).xml" />
4747
</ItemGroup>

NpgsqlRest/NpgsqlRestEndpoint.cs

Lines changed: 91 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,16 +1744,16 @@ endpoint.Cached is true &&
17441744
}
17451745
}
17461746

1747-
// Set user context BEFORE upload so that upload row commands can access user claims
1748-
if (
1749-
(endpoint.RequestHeadersMode == RequestHeadersMode.Context && headers is not null && Options.RequestHeadersContextKey is not null)
1750-
||
1751-
(endpoint.UserContext is true && Options.AuthenticationOptions.IpAddressContextKey is not null)
1752-
||
1753-
(endpoint.UserContext is true && context.User?.Identity?.IsAuthenticated is true &&
1754-
(Options.AuthenticationOptions.ClaimsJsonContextKey is not null || Options.AuthenticationOptions.ContextKeyClaimsMapping.Count > 0)
1755-
)
1756-
)
1747+
// Set user context BEFORE upload so that upload row commands can access user claims.
1748+
// Also runs BeforeRoutineCommands and (when WrapInTransaction is true) opens the request transaction here.
1749+
bool willSetHeadersContext = endpoint.RequestHeadersMode == RequestHeadersMode.Context && headers is not null && Options.RequestHeadersContextKey is not null;
1750+
bool willSetUserContext = endpoint.UserContext is true && (
1751+
Options.AuthenticationOptions.IpAddressContextKey is not null
1752+
|| (context.User?.Identity?.IsAuthenticated is true &&
1753+
(Options.AuthenticationOptions.ClaimsJsonContextKey is not null || Options.AuthenticationOptions.ContextKeyClaimsMapping.Count > 0))
1754+
);
1755+
bool willRunBeforeRoutineCommands = Options.BeforeRoutineCommands.Length > 0;
1756+
if (willSetHeadersContext || willSetUserContext || willRunBeforeRoutineCommands || Options.WrapInTransaction)
17571757
{
17581758
if (connection.State != ConnectionState.Open)
17591759
{
@@ -1763,47 +1763,90 @@ endpoint.Cached is true &&
17631763
}
17641764
await connection.OpenRetryAsync(Options.ConnectionRetryOptions, cancellationToken);
17651765
}
1766-
await using var batch = NpgsqlRestBatch.Create(connection);
17671766

1768-
if (endpoint.RequestHeadersMode == RequestHeadersMode.Context && headers is not null && Options.RequestHeadersContextKey is not null)
1767+
if (Options.WrapInTransaction && transaction is null)
17691768
{
1770-
var cmd = new NpgsqlBatchCommand(Consts.SetContext);
1771-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.RequestHeadersContextKey));
1772-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(headers));
1773-
batch.BatchCommands.Add(cmd);
1769+
transaction = await connection.BeginTransactionAsync(cancellationToken);
17741770
}
17751771

1776-
if (endpoint.UserContext is true)
1772+
string setContextSql = transaction is not null ? Consts.SetContextLocal : Consts.SetContext;
1773+
1774+
if (willSetHeadersContext || willSetUserContext || willRunBeforeRoutineCommands)
17771775
{
1778-
claimsDict ??= context.User.BuildClaimsDictionary(Options.AuthenticationOptions);
1776+
await using var batch = NpgsqlRestBatch.Create(connection);
17791777

1780-
if (Options.AuthenticationOptions.IpAddressContextKey is not null)
1778+
if (willSetHeadersContext)
17811779
{
1782-
var cmd = new NpgsqlBatchCommand(Consts.SetContext);
1783-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.AuthenticationOptions.IpAddressContextKey));
1784-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(context.Request.GetClientIpAddressDbParam()));
1780+
var cmd = new NpgsqlBatchCommand(setContextSql);
1781+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.RequestHeadersContextKey));
1782+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(headers));
17851783
batch.BatchCommands.Add(cmd);
17861784
}
1787-
if (context.User?.Identity?.IsAuthenticated is true && Options.AuthenticationOptions.ClaimsJsonContextKey is not null)
1785+
1786+
if (endpoint.UserContext is true)
17881787
{
1789-
var cmd = new NpgsqlBatchCommand(Consts.SetContext);
1790-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.AuthenticationOptions.ClaimsJsonContextKey));
1791-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(claimsDict?.GetUserClaimsDbParam() ?? DBNull.Value));
1792-
batch.BatchCommands.Add(cmd);
1788+
claimsDict ??= context.User.BuildClaimsDictionary(Options.AuthenticationOptions);
1789+
1790+
if (Options.AuthenticationOptions.IpAddressContextKey is not null)
1791+
{
1792+
var cmd = new NpgsqlBatchCommand(setContextSql);
1793+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.AuthenticationOptions.IpAddressContextKey));
1794+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(context.Request.GetClientIpAddressDbParam()));
1795+
batch.BatchCommands.Add(cmd);
1796+
}
1797+
if (context.User?.Identity?.IsAuthenticated is true && Options.AuthenticationOptions.ClaimsJsonContextKey is not null)
1798+
{
1799+
var cmd = new NpgsqlBatchCommand(setContextSql);
1800+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.AuthenticationOptions.ClaimsJsonContextKey));
1801+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(claimsDict?.GetUserClaimsDbParam() ?? DBNull.Value));
1802+
batch.BatchCommands.Add(cmd);
1803+
}
1804+
if (context.User?.Identity?.IsAuthenticated is true)
1805+
{
1806+
foreach (var mapping in Options.AuthenticationOptions.ContextKeyClaimsMapping)
1807+
{
1808+
var cmd = new NpgsqlBatchCommand(setContextSql);
1809+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(mapping.Key));
1810+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(claimsDict!.GetClaimDbContextParam(mapping.Value)));
1811+
batch.BatchCommands.Add(cmd);
1812+
}
1813+
}
17931814
}
1794-
if (context.User?.Identity?.IsAuthenticated is true)
1815+
1816+
if (willRunBeforeRoutineCommands)
17951817
{
1796-
foreach (var mapping in Options.AuthenticationOptions.ContextKeyClaimsMapping)
1818+
foreach (var beforeCmd in Options.BeforeRoutineCommands)
17971819
{
1798-
var cmd = new NpgsqlBatchCommand(Consts.SetContext);
1799-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(mapping.Key));
1800-
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(claimsDict!.GetClaimDbContextParam(mapping.Value)));
1820+
var cmd = new NpgsqlBatchCommand(beforeCmd.Sql);
1821+
foreach (var p in beforeCmd.Parameters)
1822+
{
1823+
object value;
1824+
switch (p.Source)
1825+
{
1826+
case BeforeRoutineCommandParameterSource.Claim:
1827+
claimsDict ??= context.User.BuildClaimsDictionary(Options.AuthenticationOptions);
1828+
value = p.Name is null ? DBNull.Value : claimsDict!.GetClaimDbContextParam(p.Name);
1829+
break;
1830+
case BeforeRoutineCommandParameterSource.RequestHeader:
1831+
value = p.Name is not null && context.Request.Headers.TryGetValue(p.Name, out var headerValue)
1832+
? (object)headerValue.ToString()
1833+
: DBNull.Value;
1834+
break;
1835+
case BeforeRoutineCommandParameterSource.IpAddress:
1836+
value = context.Request.GetClientIpAddressDbParam();
1837+
break;
1838+
default:
1839+
value = DBNull.Value;
1840+
break;
1841+
}
1842+
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(value));
1843+
}
18011844
batch.BatchCommands.Add(cmd);
18021845
}
18031846
}
1804-
}
18051847

1806-
await batch.ExecuteBatchWithRetryAsync(endpoint.RetryStrategy, cancellationToken);
1848+
await batch.ExecuteBatchWithRetryAsync(endpoint.RetryStrategy, cancellationToken);
1849+
}
18071850
}
18081851

18091852
object? uploadMetadata = null;
@@ -1817,7 +1860,7 @@ endpoint.Cached is true &&
18171860
}
18181861
await connection.OpenRetryAsync(Options.ConnectionRetryOptions, cancellationToken);
18191862
}
1820-
if (uploadHandler.RequiresTransaction is true)
1863+
if (uploadHandler.RequiresTransaction is true && transaction is null)
18211864
{
18221865
transaction = await connection.BeginTransactionAsync(cancellationToken);
18231866
}
@@ -1845,7 +1888,7 @@ uploadHandler is not null &&
18451888
await connection.OpenRetryAsync(Options.ConnectionRetryOptions, cancellationToken);
18461889
}
18471890
await using var batch = NpgsqlRestBatch.Create(connection);
1848-
var cmd = new NpgsqlBatchCommand(Consts.SetContext);
1891+
var cmd = new NpgsqlBatchCommand(transaction is not null ? Consts.SetContextLocal : Consts.SetContext);
18491892
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(Options.UploadOptions.DefaultUploadMetadataContextKey));
18501893
cmd.Parameters.Add(NpgsqlRestParameter.CreateTextParam(uploadMetadata));
18511894
batch.BatchCommands.Add(cmd);
@@ -3088,7 +3131,18 @@ await Results.Problem(
30883131
uploadHandler?.OnError(connection, context, exception);
30893132
}
30903133

3091-
if (context.Response.StatusCode != 200 && context.Response.StatusCode != 205 && context.Response.StatusCode != 400)
3134+
if (context.Response.StatusCode == 400)
3135+
{
3136+
if (shouldLog && cmdLog is not null)
3137+
{
3138+
Logger?.LogWarning("Client error (400) executing command: {commandText} mapped to endpoint: {Url}: {message}{NewLine}{cmdLog}", commandText, endpoint.Path, exception.Message, Environment.NewLine, cmdLog.ToString());
3139+
}
3140+
else
3141+
{
3142+
Logger?.LogWarning("Client error (400) executing command: {commandText} mapped to endpoint: {Url}: {message}", commandText, endpoint.Path, exception.Message);
3143+
}
3144+
}
3145+
else if (context.Response.StatusCode != 200 && context.Response.StatusCode != 205)
30923146
{
30933147
if (shouldLog && cmdLog is not null)
30943148
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace NpgsqlRest;
2+
3+
/// <summary>
4+
/// A SQL command executed before the main routine call, after any context (request headers, claims, IP) is set.
5+
/// Commands run in the same batch as the context set_config calls, so they share a network round-trip.
6+
/// Useful for setting per-request GUCs derived from claims (e.g. multi-tenant search_path) or for any other pre-routine setup.
7+
/// </summary>
8+
public class BeforeRoutineCommand
9+
{
10+
/// <summary>
11+
/// SQL text to execute. May contain positional parameters ($1, $2, ...) bound from the Parameters list at request time.
12+
/// </summary>
13+
public string Sql { get; set; } = "";
14+
15+
/// <summary>
16+
/// Optional list of parameter sources. Resolved at request time from claims, request headers, or the client IP.
17+
/// </summary>
18+
public BeforeRoutineCommandParameter[] Parameters { get; set; } = [];
19+
20+
/// <summary>
21+
/// Implicit conversion from a plain SQL string for ergonomic configuration of parameterless commands.
22+
/// Lets callers write <c>"select set_config(...)"</c> directly inside the BeforeRoutineCommands array.
23+
/// </summary>
24+
public static implicit operator BeforeRoutineCommand(string sql) => new() { Sql = sql, Parameters = [] };
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace NpgsqlRest;
2+
3+
/// <summary>
4+
/// Describes how to resolve a single positional parameter for a BeforeRoutineCommand at request time.
5+
/// </summary>
6+
public class BeforeRoutineCommandParameter
7+
{
8+
/// <summary>
9+
/// Where the parameter value comes from.
10+
/// </summary>
11+
public BeforeRoutineCommandParameterSource Source { get; set; }
12+
13+
/// <summary>
14+
/// Source-dependent name. For Claim: claim type. For RequestHeader: header name. For IpAddress: ignored.
15+
/// </summary>
16+
public string? Name { get; set; }
17+
}

NpgsqlRest/Options/NpgsqlRestOptions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,25 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource)
252252
/// </summary>
253253
public string RequestHeadersParameterName { get; set; } = "headers";
254254

255+
/// <summary>
256+
/// When true, **every request** is wrapped in an explicit BEGIN/COMMIT, and all `set_config` calls switch to the
257+
/// transaction-local form (`is_local=true`).
258+
/// **Required for connection poolers in transaction mode** (PgBouncer transaction-pool, AWS RDS Proxy in transaction
259+
/// mode, Supabase Pooler) — without this, the backend can be reused across unrelated requests, allowing GUC state
260+
/// (and the routine call itself) to leak or split mid-request. Default is false to preserve existing behavior;
261+
/// safe to leave off when using Npgsql's native pool only.
262+
/// </summary>
263+
public bool WrapInTransaction { get; set; } = false;
264+
265+
/// <summary>
266+
/// SQL commands executed after any context is set but before the main routine call. They run in the same batch as the
267+
/// context `set_config` calls, so no extra round-trip. Each command can be either a raw SQL string (no parameters) or
268+
/// a `BeforeRoutineCommand` object with positional parameters bound from claims, request headers, or the client IP.
269+
/// Common use case: setting `search_path` from a tenant claim for multi-tenant deployments. Combine with
270+
/// `WrapInTransaction = true` for transaction-local scoping.
271+
/// </summary>
272+
public BeforeRoutineCommand[] BeforeRoutineCommands { get; set; } = [];
273+
255274
/// <summary>
256275
/// Callback, if defined will be executed after all endpoints are created and receive an array of routine info and endpoint info tuples `(Routine routine, RoutineEndpoint endpoint)`. Used mostly for code generation.
257276
/// </summary>

0 commit comments

Comments
 (0)