Skip to content

Commit cb409d2

Browse files
vbilopavclaude
andcommitted
Add @void annotation, @param type hints for Describe, and retry logic for all execution paths
- @void annotation forces 204 No Content on any endpoint (single or multi-command) - @param type hints passed to PostgreSQL Describe, fixing 42P18 errors for set_config etc. - All ExecuteNonQueryAsync/ExecuteReaderAsync calls now use retry variants with error code policy - ExecuteNonQueryWithRetryAsync returns Task<int> for rows-affected access Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 87d3a8c commit cb409d2

14 files changed

Lines changed: 465 additions & 18 deletions

File tree

NpgsqlRest/Auth/LogoutHandler.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@ public static async Task HandleAsync(NpgsqlCommand command, RoutineEndpoint endp
1111

1212
if (endpoint.Routine.IsVoid)
1313
{
14-
await command.ExecuteNonQueryAsync(cancellationToken);
14+
await command.ExecuteNonQueryWithRetryAsync(
15+
endpoint.RetryStrategy,
16+
cancellationToken,
17+
errorCodePolicy: endpoint.ErrorCodePolicy ?? NpgsqlRestOptions.Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
1518
await Results.SignOut().ExecuteAsync(context);
1619
await context.Response.CompleteAsync();
1720
return;
1821
}
1922

2023
List<string> schemes = new(5);
21-
await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
24+
await using NpgsqlDataReader reader = await command.ExecuteReaderWithRetryAsync(
25+
endpoint.RetryStrategy,
26+
cancellationToken,
27+
errorCodePolicy: endpoint.ErrorCodePolicy ?? NpgsqlRestOptions.Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
2228

2329
while (await reader!.ReadAsync(cancellationToken))
2430
{
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace NpgsqlRest.Defaults;
2+
3+
internal static partial class DefaultCommentParser
4+
{
5+
private static readonly string[] VoidKey = [
6+
"void",
7+
"void_result",
8+
];
9+
10+
private static void HandleVoid(
11+
RoutineEndpoint endpoint,
12+
string description)
13+
{
14+
CommentLogger?.CommentSetVoid(description);
15+
endpoint.Void = true;
16+
}
17+
}

NpgsqlRest/Defaults/DefaultCommentParser.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ internal static partial class DefaultCommentParser
251251
TrackAnnotation(line);
252252
}
253253

254+
// void
255+
// void_result
256+
else if (haveTag is true && StrEqualsToArray(wordsLower[0], VoidKey))
257+
{
258+
HandleVoid(routineEndpoint, description);
259+
TrackAnnotation(line);
260+
}
261+
254262
// separator [ value ]
255263
// raw_separator [ value ]
256264
else if (haveTag is true && StrEqualsToArray(wordsLower[0], SeparatorKey))
@@ -536,6 +544,13 @@ public static void SetCustomParameter(RoutineEndpoint endpoint, string name, str
536544
endpoint.ReturnSingleRecord = parsedSingle;
537545
}
538546
}
547+
else if (StrEqualsToArray(name, VoidKey))
548+
{
549+
if (bool.TryParse(value, out var parsedVoid))
550+
{
551+
endpoint.Void = parsedVoid;
552+
}
553+
}
539554
else if (StrEqualsToArray(name, SeparatorKey))
540555
{
541556
endpoint.RawValueSeparator = value;

NpgsqlRest/Log.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ public static partial class Log
107107
[LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set SINGLE RECORD by the comment annotation.")]
108108
public static partial void CommentSetSingleRecord(this ILogger logger, string description);
109109

110+
[LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set VOID by the comment annotation.")]
111+
public static partial void CommentSetVoid(this ILogger logger, string description);
112+
110113
[LoggerMessage(Level = LogLevel.Trace, Message = "{description} has set SEPARATOR by the comment annotation to {value}.")]
111114
public static partial void CommentSetRawValueSeparator(this ILogger logger, string description, string value);
112115

NpgsqlRest/NpgsqlRestEndpoint.cs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,35 @@ await LoginHandler.HandleAsync(
19191919
NpgsqlRestLogger.LogEndpoint(endpoint, cmdLog?.ToString() ?? "", allSql);
19201920
}
19211921

1922+
// Void multi-command: execute all statements, return 204
1923+
if (endpoint.Void)
1924+
{
1925+
for (int cmdIndex = 0; cmdIndex < routine.MultiCommandInfo.Length; cmdIndex++)
1926+
{
1927+
var currentCmd = routine.MultiCommandInfo[cmdIndex];
1928+
await using var mcCmd = new NpgsqlCommand(currentCmd.Statement, connection);
1929+
if (endpoint.CommandTimeout.HasValue)
1930+
{
1931+
mcCmd.CommandTimeout = endpoint.CommandTimeout.Value.Seconds;
1932+
}
1933+
for (int pi = 0; pi < Math.Min(currentCmd.ParamCount, command.Parameters.Count); pi++)
1934+
{
1935+
var srcParam = command.Parameters[pi];
1936+
mcCmd.Parameters.Add(new NpgsqlParameter
1937+
{
1938+
NpgsqlDbType = srcParam.NpgsqlDbType,
1939+
Value = srcParam.Value ?? DBNull.Value
1940+
});
1941+
}
1942+
await mcCmd.ExecuteNonQueryWithRetryAsync(
1943+
endpoint.RetryStrategy,
1944+
cancellationToken,
1945+
errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
1946+
}
1947+
context.Response.StatusCode = (int)HttpStatusCode.NoContent;
1948+
return;
1949+
}
1950+
19221951
context.Response.ContentType = Application.Json;
19231952

19241953
// Write opening {
@@ -1949,7 +1978,10 @@ await LoginHandler.HandleAsync(
19491978
// Skipped commands: execute for side effects, no result key
19501979
if (currentCmd.IsSkipped)
19511980
{
1952-
await mcCmd.ExecuteNonQueryAsync(cancellationToken);
1981+
await mcCmd.ExecuteNonQueryWithRetryAsync(
1982+
endpoint.RetryStrategy,
1983+
cancellationToken,
1984+
errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
19531985
continue;
19541986
}
19551987

@@ -1967,7 +1999,10 @@ await LoginHandler.HandleAsync(
19671999
if (currentCmd.ColumnCount == 0)
19682000
{
19692001
// Void command — execute and write rows affected count
1970-
var rowsAffected = await mcCmd.ExecuteNonQueryAsync(cancellationToken);
2002+
var rowsAffected = await mcCmd.ExecuteNonQueryWithRetryAsync(
2003+
endpoint.RetryStrategy,
2004+
cancellationToken,
2005+
errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
19712006
var rowsStr = rowsAffected.ToString();
19722007
writer.Advance(Encoding.UTF8.GetBytes(rowsStr.AsSpan(), writer.GetSpan(Encoding.UTF8.GetMaxByteCount(rowsStr.Length))));
19732008
}
@@ -1976,7 +2011,10 @@ await LoginHandler.HandleAsync(
19762011
// Query command — read result set as JSON array (or object if @single)
19772012
// AllResultTypesAreUnknown forces all values to string — same as single-command path
19782013
mcCmd.AllResultTypesAreUnknown = true;
1979-
await using var mcReader = await mcCmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken);
2014+
await using var mcReader = await mcCmd.ExecuteReaderWithRetryAsync(
2015+
endpoint.RetryStrategy,
2016+
cancellationToken,
2017+
errorCodePolicy: endpoint.ErrorCodePolicy ?? Options.ErrorHandlingOptions.DefaultErrorCodePolicy);
19802018

19812019
if (!currentCmd.IsSingle)
19822020
{
@@ -2229,7 +2267,7 @@ mcDescriptor.CompositeFieldNames is not null &&
22292267
return;
22302268
}
22312269

2232-
if (routine.IsVoid)
2270+
if (routine.IsVoid || endpoint.Void)
22332271
{
22342272
if (await PrepareCommand(connection, command, commandText, context, endpoint, true, cancellationToken) is false)
22352273
{

NpgsqlRest/Retries/NpgsqlCommandRetry.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,25 @@ public static void ExecuteNonQueryWithRetry(
5454
}
5555
}
5656

57-
public static async Task ExecuteNonQueryWithRetryAsync(
58-
this NpgsqlCommand command,
57+
public static async Task<int> ExecuteNonQueryWithRetryAsync(
58+
this NpgsqlCommand command,
5959
RetryStrategy? strategy,
6060
CancellationToken cancellationToken = default,
6161
ILogger? logger = null,
6262
string? errorCodePolicy = null)
6363
{
6464
if (strategy == null || strategy.RetrySequenceSeconds.Length == 0)
6565
{
66-
await command.ExecuteNonQueryAsync(cancellationToken);
67-
return;
66+
return await command.ExecuteNonQueryAsync(cancellationToken);
6867
}
6968
var maxRetries = strategy.RetrySequenceSeconds.Length;
7069
var exceptionsEncountered = new List<Exception>(maxRetries);
71-
70+
7271
for (var attempt = 0; attempt <= maxRetries; attempt++)
7372
{
7473
try
7574
{
76-
await command.ExecuteNonQueryAsync(cancellationToken);
77-
return;
75+
return await command.ExecuteNonQueryAsync(cancellationToken);
7876
}
7977
catch (PostgresException ex) when (errorCodePolicy is not null && errorCodePolicy.TryGetErrorCodeMapping(ex.SqlState, out var statusCodeMapping))
8078
{
@@ -115,8 +113,9 @@ public static async Task ExecuteNonQueryWithRetryAsync(
115113
throw;
116114
}
117115
}
116+
return 0; // unreachable — loop always returns or throws
118117
}
119-
118+
120119
public static NpgsqlDataReader ExecuteReaderWithRetry(
121120
this NpgsqlCommand command,
122121
RetryStrategy? strategy,

NpgsqlRest/RoutineEndpoint.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,11 @@ internal void EnsurePathParametersHashSet()
265265
/// instead of the default flat structure {"id": 1, "name": "test"}.
266266
/// </summary>
267267
public bool? NestedJsonForCompositeTypes { get; set; } = null;
268+
269+
/// <summary>
270+
/// When true, the endpoint is forced to behave as void — all statements are executed
271+
/// but no result is returned. Returns 204 No Content.
272+
/// Configured via the "void" comment annotation.
273+
/// </summary>
274+
public bool Void { get; set; } = false;
268275
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using NpgsqlRest.SqlFileSource;
2+
3+
namespace NpgsqlRestTests.SqlFileSourceTests;
4+
5+
public class ParamTypeHintTests
6+
{
7+
[Fact]
8+
public void SimpleParamType_ExtractsHint()
9+
{
10+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name text");
11+
hints.Should().NotBeNull();
12+
hints![0].Should().Be("text");
13+
}
14+
15+
[Fact]
16+
public void MultipleParams_ExtractsAll()
17+
{
18+
var comment = """
19+
@param $1 message_text text
20+
@param $2 user_id integer
21+
@param $3 active boolean
22+
""";
23+
var hints = SqlFileParser.ExtractParamTypeHints(comment);
24+
hints.Should().NotBeNull();
25+
hints!.Should().HaveCount(3);
26+
hints[0].Should().Be("text");
27+
hints[1].Should().Be("integer");
28+
hints[2].Should().Be("boolean");
29+
}
30+
31+
[Fact]
32+
public void IsStyle_ExtractsHint()
33+
{
34+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 is user_id integer");
35+
hints.Should().NotBeNull();
36+
hints![0].Should().Be("integer");
37+
}
38+
39+
[Fact]
40+
public void NoType_ReturnsNull()
41+
{
42+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name");
43+
hints.Should().BeNull();
44+
}
45+
46+
[Fact]
47+
public void DefaultAfterName_NotTreatedAsType()
48+
{
49+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name default null");
50+
hints.Should().BeNull();
51+
}
52+
53+
[Fact]
54+
public void EqualsAfterName_NotTreatedAsType()
55+
{
56+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name = null");
57+
hints.Should().BeNull();
58+
}
59+
60+
[Fact]
61+
public void TypeWithDefault_ExtractsType()
62+
{
63+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name text default null");
64+
hints.Should().NotBeNull();
65+
hints![0].Should().Be("text");
66+
}
67+
68+
[Fact]
69+
public void TypeWithEquals_ExtractsType()
70+
{
71+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name text = null");
72+
hints.Should().NotBeNull();
73+
hints![0].Should().Be("text");
74+
}
75+
76+
[Fact]
77+
public void NullComment_ReturnsNull()
78+
{
79+
var hints = SqlFileParser.ExtractParamTypeHints(null);
80+
hints.Should().BeNull();
81+
}
82+
83+
[Fact]
84+
public void EmptyComment_ReturnsNull()
85+
{
86+
var hints = SqlFileParser.ExtractParamTypeHints("");
87+
hints.Should().BeNull();
88+
}
89+
90+
[Fact]
91+
public void NonPositionalParam_Ignored()
92+
{
93+
var hints = SqlFileParser.ExtractParamTypeHints("@param user_name default null");
94+
hints.Should().BeNull();
95+
}
96+
97+
[Fact]
98+
public void UnknownType_Ignored()
99+
{
100+
var hints = SqlFileParser.ExtractParamTypeHints("@param $1 name somefaketype");
101+
hints.Should().BeNull();
102+
}
103+
104+
[Fact]
105+
public void ParameterLongForm_Works()
106+
{
107+
var hints = SqlFileParser.ExtractParamTypeHints("@parameter $1 name integer");
108+
hints.Should().NotBeNull();
109+
hints![0].Should().Be("integer");
110+
}
111+
112+
[Fact]
113+
public void MixedWithAndWithoutTypes_OnlyExtractsTyped()
114+
{
115+
var comment = """
116+
@param $1 message_text text
117+
@param $2 user_id
118+
@param $3 active boolean
119+
""";
120+
var hints = SqlFileParser.ExtractParamTypeHints(comment);
121+
hints.Should().NotBeNull();
122+
hints!.Should().HaveCount(2);
123+
hints[0].Should().Be("text");
124+
hints[2].Should().Be("boolean");
125+
}
126+
127+
[Fact]
128+
public void WithAtPrefix_Works()
129+
{
130+
var hints = SqlFileParser.ExtractParamTypeHints("param $1 name text");
131+
hints.Should().NotBeNull();
132+
hints![0].Should().Be("text");
133+
}
134+
}

NpgsqlRestTests/SqlFileSourceTests/QueryEndpointTests/ParamDefaultValueTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ public static void ParamDefaultValueTests()
107107
-- @param val =
108108
select coalesce($1, 'was_null') as result;
109109
""");
110+
111+
// @param type hint enables Describe for set_config (PostgreSQL can't infer $1 type without hint)
112+
File.WriteAllText(Path.Combine(Dir, "param_type_hint_set_config.sql"), """
113+
-- @param $1 my_key text
114+
-- @param $2 my_value text
115+
select set_config($1, $2, true) as result;
116+
""");
110117
}
111118
}
112119

@@ -420,4 +427,16 @@ public async Task EqualsBare_NotProvided_ReturnsCoalescedNull()
420427
var content = await response.Content.ReadAsStringAsync();
421428
content.Should().Contain("was_null");
422429
}
430+
431+
// --- @param type hint for set_config ---
432+
433+
[Fact]
434+
public async Task ParamTypeHint_SetConfig_EndpointExists()
435+
{
436+
using var response = await test.Client.GetAsync("/api/param-type-hint-set-config?my_key=test.key&my_value=hello");
437+
response.StatusCode.Should().Be(HttpStatusCode.OK);
438+
439+
var content = await response.Content.ReadAsStringAsync();
440+
content.Should().Be("""["hello"]""");
441+
}
423442
}

0 commit comments

Comments
 (0)