From 6a0f03f830a521241d34b15a46bc0d2439f5d0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Tue, 30 Jun 2026 12:17:48 +0200 Subject: [PATCH 01/14] feat: SQL test runner (--test) [WIP], SqlFileSource SkipPattern, MinimalLevels Off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL test runner (--test) — WIP, not yet in changelog (still stabilizing): - Discovers *.test.sql, runs each on its own non-pooled connection, invokes endpoints in-process on the test's own connection/transaction, asserts via boolean SELECTs / DO blocks / `# @expect-status`. Captures responses into per-block pg_temp tables. - Per-assertion reporting (each boolean SELECT / DO block / expect-status HTTP step is one test); console grouped per file, JUnit one per assertion. - Dedicated `NpgsqlRestTest` log channel (configurable TestRunner.LoggerName, leveled independently via Log:MinimalLevels): discovery/parsing at Debug, queries + HTTP calls at Verbose, notices by severity. Console report stays separate. - Core (additive, inert when unused): NpgsqlRestOptions.AmbientConnectionAccessor + connection-selection branch; RoutineInvokeResult.Headers; endpoint notice-handler leak fix (named delegate detached in finally); NpgsqlRestLogger.LogConnectionNotice ILogger overload. - 33 parser unit tests (NpgsqlRestTests/TestRunnerTests/ParserTests/). Shippable in 3.19.0 (changelog/v3.19.0.md): - SqlFileSource.SkipPattern (default *.test.sql) — exclude files from endpoint discovery; included iff matches FilePattern AND not SkipPattern. - Log:MinimalLevels accepts "Off"/"None"/"Silent" to fully mute a logger (minimum level above Fatal); null/omitted still falls back to the default. Config synced across appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs. Full suite green (2334) + 33 parser + 94 config tests. --- NpgsqlRest/NpgsqlRestEndpoint.cs | 21 +- NpgsqlRest/NpgsqlRestLogger.cs | 59 +- NpgsqlRest/Options/NpgsqlRestOptions.cs | 8 + NpgsqlRest/RoutineInvoker.cs | 4 +- NpgsqlRestClient/App.cs | 10 +- NpgsqlRestClient/Builder.cs | 127 +++- NpgsqlRestClient/Config.cs | 2 +- NpgsqlRestClient/ConfigDefaults.cs | 34 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 18 +- NpgsqlRestClient/ConfigTemplate.cs | 87 ++- NpgsqlRestClient/Out.cs | 17 + NpgsqlRestClient/Program.cs | 33 + .../Testing/HttpFileRequestParser.cs | 150 ++++ NpgsqlRestClient/Testing/SqlTestFileParser.cs | 229 ++++++ NpgsqlRestClient/Testing/TestRunner.cs | 708 ++++++++++++++++++ NpgsqlRestClient/Testing/TestRunnerOptions.cs | 83 ++ NpgsqlRestClient/Testing/TestStep.cs | 75 ++ NpgsqlRestClient/appsettings.json | 85 +++ .../ParserTests/HttpFileRequestParserTests.cs | 129 ++++ .../ParserTests/SqlTestFileParserTests.cs | 131 ++++ changelog/v3.19.0.md | 46 ++ .../NpgsqlRest.SqlFileSource/SqlFileSource.cs | 8 +- .../SqlFileSourceOptions.cs | 7 + 23 files changed, 2040 insertions(+), 31 deletions(-) create mode 100644 NpgsqlRestClient/Testing/HttpFileRequestParser.cs create mode 100644 NpgsqlRestClient/Testing/SqlTestFileParser.cs create mode 100644 NpgsqlRestClient/Testing/TestRunner.cs create mode 100644 NpgsqlRestClient/Testing/TestRunnerOptions.cs create mode 100644 NpgsqlRestClient/Testing/TestStep.cs create mode 100644 NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs create mode 100644 NpgsqlRestTests/TestRunnerTests/ParserTests/SqlTestFileParserTests.cs create mode 100644 changelog/v3.19.0.md diff --git a/NpgsqlRest/NpgsqlRestEndpoint.cs b/NpgsqlRest/NpgsqlRestEndpoint.cs index dfd84d93..3aa0250b 100644 --- a/NpgsqlRest/NpgsqlRestEndpoint.cs +++ b/NpgsqlRest/NpgsqlRestEndpoint.cs @@ -71,6 +71,9 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi string? commandText = null; bool shouldDispose = true; bool shouldCommit = true; + // Per-request connection notice handler; detached in the finally so it never accumulates on a + // reused connection (e.g. ServiceProviderMode=NpgsqlConnection / the SQL test runner's ambient connection). + NoticeEventHandler? noticeHandler = null; // Held (when non-null) for the streaming records/sets cache path; released in the outer finally. SemaphoreSlim? recordCacheGate = null; IUploadHandler? uploadHandler = null; @@ -102,7 +105,14 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi : PipeWriter.Create(context.Response.Body); try { - if (endpoint.ConnectionName is not null) + if (Options.AmbientConnectionAccessor?.Invoke() is { } ambientConnection) + { + // Ambient connection override (SQL test runner): run on the caller's already-open + // connection/transaction and never dispose it. The caller owns the connection lifecycle. + connection = ambientConnection; + shouldDispose = false; + } + else if (endpoint.ConnectionName is not null) { // First check if there's a data source for this connection name (for multi-host support) if (Options.DataSources?.TryGetValue(endpoint.ConnectionName, out var namedDataSource) is true) @@ -171,7 +181,7 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi || noticeWarnEnabled) { var currentEndpointCaptured = endpoint; - connection.Notice += (sender, args) => + noticeHandler = (sender, args) => { if (Options.LogConnectionNoticeEvents && Logger != null) { @@ -192,6 +202,7 @@ public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvi Logger!.UnboundSseRaise(args.Notice.Severity, currentEndpointCaptured.Path); } }; + connection.Notice += noticeHandler; } if (Options.AuthenticationOptions.BasicAuth.Enabled is true || endpoint.BasicAuth?.Enabled is true) @@ -3337,6 +3348,12 @@ await Proxy.ProxyRequestHandler.WriteResponseAsync( } } } + // Detach the per-request notice handler so it can't accumulate (and re-log) on a reused + // connection that this request did not dispose. + if (connection is not null && noticeHandler is not null) + { + connection.Notice -= noticeHandler; + } if (connection is not null && shouldDispose is true) { await connection.DisposeAsync(); diff --git a/NpgsqlRest/NpgsqlRestLogger.cs b/NpgsqlRest/NpgsqlRestLogger.cs index bc9dde74..393d9809 100644 --- a/NpgsqlRest/NpgsqlRestLogger.cs +++ b/NpgsqlRest/NpgsqlRestLogger.cs @@ -17,27 +17,27 @@ public static class NpgsqlRestLogger private static readonly Action __LogTraceCallback = LoggerMessage.Define(LogLevel.Trace, 4, LogPattern, LogDefineOptions); - private static void LogInformation(string? where, string message) + private static void LogInformation(ILogger logger, string? where, string message) { - if (Logger?.IsEnabled(LogLevel.Information) is true) + if (logger.IsEnabled(LogLevel.Information)) { - __LogInformationCallback(Logger, where, message, null); + __LogInformationCallback(logger, where, message, null); } } - private static void LogWarning(string? where, string message) + private static void LogWarning(ILogger logger, string? where, string message) { - if (Logger?.IsEnabled(LogLevel.Warning) is true) + if (logger.IsEnabled(LogLevel.Warning)) { - __LogWarningCallback(Logger, where, message, null); + __LogWarningCallback(logger, where, message, null); } } - private static void LogTrace(string? where, string message) + private static void LogTrace(ILogger logger, string? where, string message) { - if (Logger?.IsEnabled(LogLevel.Trace) is true) + if (logger.IsEnabled(LogLevel.Trace)) { - __LogTraceCallback(Logger, where, message, null); + __LogTraceCallback(logger, where, message, null); } } @@ -52,55 +52,76 @@ public static void LogEndpoint(RoutineEndpoint endpoint, string parameters, stri #pragma warning disable IDE0079 // Remove unnecessary suppression [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2254:Template should be a static expression", Justification = "")] #pragma warning restore IDE0079 // Remove unnecessary suppression - public static void LogConnectionNotice(PostgresNotice notice, PostgresConnectionNoticeLoggingMode mode) + public static void LogConnectionNotice(PostgresNotice notice, PostgresConnectionNoticeLoggingMode mode, string? context = null) { if (Logger is null) { return; } + LogConnectionNotice(Logger, notice, mode, context); + } + + /// + /// Logs a connection notice through the supplied rather than the static + /// . Used by the SQL test runner so notices route through its own log channel + /// (e.g. "NpgsqlRestTest"). The log level still follows the notice severity (info/notice => Information, + /// warning => Warning, otherwise Trace/Verbose) and the format is identical to the normal-app path. + /// +#pragma warning disable IDE0079 // Remove unnecessary suppression + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2254:Template should be a static expression", Justification = "")] +#pragma warning restore IDE0079 // Remove unnecessary suppression + public static void LogConnectionNotice(ILogger logger, PostgresNotice notice, PostgresConnectionNoticeLoggingMode mode, string? context = null) + { + if (logger is null) + { + return; + } + // Optional caller context (e.g. the SQL test runner's current test file) prefixed to the location, + // so notices can be attributed to their source. Null in normal operation → format unchanged. + string? tag = string.IsNullOrEmpty(context) ? null : $"[{context}] "; if (notice.IsInfo() || notice.IsNotice()) { if (mode == PostgresConnectionNoticeLoggingMode.MessageOnly) { - Logger.LogInformation(notice.MessageText); + logger.LogInformation(string.Concat(tag, notice.MessageText)); } else if (mode == PostgresConnectionNoticeLoggingMode.FirstStackFrameAndMessage) { - LogInformation(notice?.Where?.Split('\n').LastOrDefault() ?? "", notice?.MessageText!); + LogInformation(logger, string.Concat(tag, notice?.Where?.Split('\n').LastOrDefault() ?? ""), notice?.MessageText!); } else if (mode == PostgresConnectionNoticeLoggingMode.FullStackAndMessage) { - LogInformation(notice?.Where, notice?.MessageText!); + LogInformation(logger, string.Concat(tag, notice?.Where), notice?.MessageText!); } } else if (notice.IsWarning()) { if (mode == PostgresConnectionNoticeLoggingMode.MessageOnly) { - Logger.LogWarning(notice.MessageText); + logger.LogWarning(string.Concat(tag, notice.MessageText)); } else if (mode == PostgresConnectionNoticeLoggingMode.FirstStackFrameAndMessage) { - LogWarning(notice?.Where?.Split('\n').Last() ?? "", notice?.MessageText!); + LogWarning(logger, string.Concat(tag, notice?.Where?.Split('\n').Last() ?? ""), notice?.MessageText!); } else if (mode == PostgresConnectionNoticeLoggingMode.FullStackAndMessage) { - LogWarning(notice?.Where, notice?.MessageText!); + LogWarning(logger, string.Concat(tag, notice?.Where), notice?.MessageText!); } } else { if (mode == PostgresConnectionNoticeLoggingMode.MessageOnly) { - Logger.LogTrace(notice.MessageText); + logger.LogTrace(string.Concat(tag, notice.MessageText)); } else if (mode == PostgresConnectionNoticeLoggingMode.FirstStackFrameAndMessage) { - LogTrace(notice?.Where?.Split('\n').Last() ?? "", notice?.MessageText!); + LogTrace(logger, string.Concat(tag, notice?.Where?.Split('\n').Last() ?? ""), notice?.MessageText!); } else if (mode == PostgresConnectionNoticeLoggingMode.FullStackAndMessage) { - LogTrace(notice?.Where, notice?.MessageText!); + LogTrace(logger, string.Concat(tag, notice?.Where), notice?.MessageText!); } } } diff --git a/NpgsqlRest/Options/NpgsqlRestOptions.cs b/NpgsqlRest/Options/NpgsqlRestOptions.cs index 719b1807..1c87d19b 100644 --- a/NpgsqlRest/Options/NpgsqlRestOptions.cs +++ b/NpgsqlRest/Options/NpgsqlRestOptions.cs @@ -150,6 +150,14 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource) /// public ServiceProviderObject ServiceProviderMode { get; set; } = ServiceProviderObject.None; + /// + /// When set and the accessor returns a non-null connection, the endpoint executes on that ambient + /// connection (and does not dispose it) instead of opening its own. Null by default — zero behavioral + /// change for normal operation. Used by the SQL test runner to run an endpoint on the test's own + /// open connection/transaction (connection affinity). + /// + public Func? AmbientConnectionAccessor { get; set; } = null; + /// /// Callback function that is executed just after the new endpoint is created. Set the RoutineEndpoint to null to disable endpoint. /// diff --git a/NpgsqlRest/RoutineInvoker.cs b/NpgsqlRest/RoutineInvoker.cs index 86938da0..142e3aee 100644 --- a/NpgsqlRest/RoutineInvoker.cs +++ b/NpgsqlRest/RoutineInvoker.cs @@ -6,7 +6,7 @@ namespace NpgsqlRest; /// /// Result of a programmatic routine invocation via . /// -public readonly record struct RoutineInvokeResult(int StatusCode, string? Body, string? ContentType, bool IsSuccess); +public readonly record struct RoutineInvokeResult(int StatusCode, string? Body, string? ContentType, string? Headers, bool IsSuccess); /// /// Public entry point for invoking an NpgsqlRest endpoint in-process (no network hop), running the @@ -43,6 +43,6 @@ public static async Task InvokeAsync( var response = await InternalRequestHandler.ExecuteAsync( method, path, headerDict, body, contentType, cancellationToken, user); - return new RoutineInvokeResult(response.StatusCode, response.Body, response.ContentType, response.IsSuccess); + return new RoutineInvokeResult(response.StatusCode, response.Body, response.ContentType, response.Headers, response.IsSuccess); } } diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index 9825f720..dd55f2b6 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -463,8 +463,10 @@ public string CreateUrl(Routine routine, NpgsqlRestOptions options) => public List CreateCodeGenHandlers(string connectionString, string[] args) { List handlers = new(3); - if (args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase))) + if (args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase) + || string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase))) { + // --test also captures endpoints so the runner can pre-check kinds (SSE/upload/outbound). handlers.Add(new EndpointCapture()); } var httpFilecfg = _config.NpgsqlRestCfg.GetSection("HttpFileOptions"); @@ -661,6 +663,12 @@ public List CreateEndpointSources() UnnamedSingleColumnSet = _config.GetConfigBool("UnnamedSingleColumnSet", sqlFileSourceCfg, true), SkipNonQueryCommands = _config.GetConfigBool("SkipNonQueryCommands", sqlFileSourceCfg, true), }; + // Absent => default "*.test.sql"; explicit empty string disables the exclusion. + var skipPattern = _config.GetConfigStr("SkipPattern", sqlFileSourceCfg); + if (sqlFileSourceCfg.GetSection("SkipPattern").Exists()) + { + opts.SkipPattern = skipPattern ?? ""; + } if (_config.GetConfigStr("CommentsMode", sqlFileSourceCfg) is not null) { opts.CommentsMode = _config.GetConfigEnum("CommentsMode", sqlFileSourceCfg); diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 4610183a..d6ce259a 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -15,9 +15,11 @@ using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; +using Microsoft.Extensions.Configuration; using Npgsql; using NpgsqlRest; using NpgsqlRestClient.Fido2; +using NpgsqlRestClient.Testing; using Serilog; using Serilog.Extensions.Logging; using Serilog.Sinks.OpenTelemetry; @@ -39,6 +41,8 @@ public Builder(Config config) public Microsoft.Extensions.Logging.ILogger? Logger { get; private set; } = null; public Microsoft.Extensions.Logging.ILogger? ClientLogger { get; private set; } = null; + /// Independent log channel for the SQL test runner (--test); name from TestRunner:LoggerName (default "NpgsqlRestTest"). + public Microsoft.Extensions.Logging.ILogger? TestLogger { get; private set; } = null; public bool UseHttpsRedirection { get; private set; } = false; public bool UseHsts { get; private set; } = false; public BearerTokenConfig? BearerTokenConfig { get; private set; } = null; @@ -156,11 +160,28 @@ logToConsole is true || var appName = _config.GetConfigStr("ApplicationName", _config.Cfg); var envName = _config.GetConfigStr("EnvironmentName", _config.Cfg) ?? "Production"; string clientLoggerName = string.IsNullOrEmpty(appName) ? "NpgsqlRestClient" : appName; + // SQL test runner's own log channel (see TestRunner:LoggerName). A non-dotted sibling name so it's + // controlled independently of "NpgsqlRest" via its own MinimalLevels entry. + string testLoggerName = _config.GetConfigStr("LoggerName", _config.Cfg.GetSection("TestRunner")) ?? "NpgsqlRestTest"; + bool testLoggerAdded = false; foreach (var level in logCfg?.GetSection("MinimalLevels")?.GetChildren() ?? []) { var key = level.Key; - var value = _config.GetEnum(level.Value); + Serilog.Events.LogEventLevel? value; + if (string.IsNullOrWhiteSpace(level.Value) is false && + (string.Equals(level.Value, "Off", StringComparison.OrdinalIgnoreCase) || + string.Equals(level.Value, "None", StringComparison.OrdinalIgnoreCase) || + string.Equals(level.Value, "Silent", StringComparison.OrdinalIgnoreCase))) + { + // Mute the source entirely: a minimum above Fatal means no event (max level Fatal) passes. + // null/unrecognized still falls through to GetEnum => null => the source keeps its default. + value = (Serilog.Events.LogEventLevel)((int)Serilog.Events.LogEventLevel.Fatal + 1); + } + else + { + value = _config.GetEnum(level.Value); + } if (value is not null && key is not null) { if (string.Equals(key, "NpgsqlRest", StringComparison.OrdinalIgnoreCase)) @@ -183,6 +204,11 @@ logToConsole is true || loggerConfig.MinimumLevel.Override(key, value.Value); microsoftAdded = true; } + else if (string.Equals(key, testLoggerName, StringComparison.OrdinalIgnoreCase)) + { + loggerConfig.MinimumLevel.Override(testLoggerName, value.Value); + testLoggerAdded = true; + } else { loggerConfig.MinimumLevel.Override(key, value.Value); @@ -205,6 +231,16 @@ logToConsole is true || { loggerConfig.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning); } + // Default the test-runner channel to Information when not in MinimalLevels (skip if its name + // collides with a reserved logger, which already has its own default above). + if (testLoggerAdded is false && + string.Equals(testLoggerName, "NpgsqlRest", StringComparison.OrdinalIgnoreCase) is false && + string.Equals(testLoggerName, clientLoggerName, StringComparison.OrdinalIgnoreCase) is false && + string.Equals(testLoggerName, "System", StringComparison.OrdinalIgnoreCase) is false && + string.Equals(testLoggerName, "Microsoft", StringComparison.OrdinalIgnoreCase) is false) + { + loggerConfig.MinimumLevel.Override(testLoggerName, Serilog.Events.LogEventLevel.Information); + } string outputTemplate = _config.GetConfigStr("OutputTemplate", logCfg) ?? "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj} [{SourceContext}]{NewLine}{Exception}"; @@ -286,6 +322,9 @@ logToConsole is true || Logger = serilogFactory.CreateLogger("NpgsqlRest"); // Client application logger - uses ApplicationName or "NpgsqlRestClient" ClientLogger = serilogFactory.CreateLogger(clientLoggerName); + // Test runner logger - independent channel so `--test` activity (queries=Verbose, parsing=Debug, + // notices=by severity) is leveled separately from the app via Log:MinimalLevels. + TestLogger = serilogFactory.CreateLogger(testLoggerName); Instance.Host.UseSerilog(serilog); @@ -2963,6 +3002,92 @@ public Dictionary BuildDataSources(string mainConnecti return result; } + /// + /// Builds the SQL test-runner options from the top-level "TestRunner" config section. + /// + public TestRunnerOptions BuildTestRunnerOptions() + { + var opt = new TestRunnerOptions(); + var section = _config.Cfg.GetSection("TestRunner"); + if (section.Exists() is false) return opt; + + opt.FilePattern = _config.GetConfigStr("FilePattern", section) ?? ""; + opt.MaxParallelism = _config.GetConfigInt("MaxParallelism", section) ?? 0; + opt.FailFast = _config.GetConfigBool("FailFast", section, false); + opt.PerTestTimeout = ParseTestTimeout(_config.GetConfigStr("PerTestTimeout", section), TimeSpan.FromSeconds(30)); + opt.JUnitOutput = _config.GetConfigStr("JUnitOutput", section); + opt.Keep = _config.GetConfigBool("Keep", section, false); + opt.Verbose = _config.GetConfigBool("Verbose", section, false); + opt.AllowEmpty = _config.GetConfigBool("AllowEmpty", section, false); + + var rt = section.GetSection("ResponseTempTable"); + if (rt.Exists()) + { + opt.ResponseTempTable.Name = _config.GetConfigStr("Name", rt) ?? opt.ResponseTempTable.Name; + opt.ResponseTempTable.MultiNamePattern = _config.GetConfigStr("MultiNamePattern", rt) ?? opt.ResponseTempTable.MultiNamePattern; + var cols = rt.GetSection("Columns"); + if (cols.Exists()) + { + var c = opt.ResponseTempTable.Columns; + c.Status = ReadColumnName(cols, "Status", c.Status); + c.Body = ReadColumnName(cols, "Body", c.Body); + c.ContentType = ReadColumnName(cols, "ContentType", c.ContentType); + c.Headers = ReadColumnName(cols, "Headers", c.Headers); + c.IsSuccess = ReadColumnName(cols, "IsSuccess", c.IsSuccess); + } + } + + opt.Setup = ReadTestSteps(section.GetSection("Setup")); + opt.Teardown = ReadTestSteps(section.GetSection("Teardown")); + return opt; + + // Absent key => keep default name; explicit empty string => omit the column. + string? ReadColumnName(IConfigurationSection cols, string key, string? def) + { + var s = cols.GetSection(key); + if (s.Exists() is false) return def; + return string.IsNullOrWhiteSpace(s.Value) ? null : s.Value; + } + + List ReadTestSteps(IConfigurationSection stepsSection) + { + var list = new List(); + if (stepsSection.Exists() is false) return list; + foreach (var child in stepsSection.GetChildren()) + { + var step = new TestSetupStep + { + Sql = _config.GetConfigStr("Sql", child), + SqlFile = _config.GetConfigStr("SqlFile", child), + Command = _config.GetConfigStr("Command", child), + WorkingDirectory = _config.GetConfigStr("WorkingDirectory", child), + }; + if (step.Sql is not null || step.SqlFile is not null || step.Command is not null) + { + list.Add(step); + } + } + return list; + } + } + + private static TimeSpan ParseTestTimeout(string? raw, TimeSpan def) + { + if (string.IsNullOrWhiteSpace(raw)) return def; + raw = raw.Trim(); + if (int.TryParse(raw, out var secs)) return TimeSpan.FromSeconds(secs); + if (raw.Length > 1 && int.TryParse(raw[..^1], out var n)) + { + switch (char.ToLowerInvariant(raw[^1])) + { + case 's': return TimeSpan.FromSeconds(n); + case 'm': return TimeSpan.FromMinutes(n); + case 'h': return TimeSpan.FromHours(n); + } + } + return TimeSpan.TryParse(raw, out var ts) ? ts : def; + } + /// /// Builds the main data source (handles both single-host and multi-host). /// diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index e6badbaa..55b257e0 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -1117,7 +1117,7 @@ private static void LoadEnvFile(string path) { commandLineArgs.Add(arg); } - else if (lower is "--json" or "--validate" or "--endpoints") + else if (lower is "--json" or "--validate" or "--endpoints" or "--test") { // Known flags handled by Program.cs, skip silently } diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 5282735d..93e5ab26 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -173,6 +173,7 @@ public static JsonObject GetDefaults() ["ForwardedHeaders"] = GetForwardedHeadersDefaults(), ["HealthChecks"] = GetHealthChecksDefaults(), ["Stats"] = GetStatsDefaults(), + ["TestRunner"] = GetTestRunnerDefaults(), ["CommandRetryOptions"] = GetCommandRetryOptionsDefaults(), ["CacheOptions"] = GetCacheOptionsDefaults(), ["ValidationOptions"] = GetValidationOptionsDefaults(), @@ -359,6 +360,7 @@ private static JsonObject GetLogDefaults() { ["NpgsqlRest"] = "Information", ["NpgsqlRestClient"] = "Information", + ["NpgsqlRestTest"] = "Information", ["System"] = "Warning", ["Microsoft"] = "Warning" }, @@ -1082,6 +1084,7 @@ private static JsonObject GetSqlFileSourceDefaults() { ["Enabled"] = false, ["FilePattern"] = "", + ["SkipPattern"] = "*.test.sql", ["CommentsMode"] = "OnlyAnnotated", ["CommentScope"] = "All", ["ErrorMode"] = "Exit", @@ -1093,6 +1096,37 @@ private static JsonObject GetSqlFileSourceDefaults() }; } + private static JsonObject GetTestRunnerDefaults() + { + return new JsonObject + { + ["FilePattern"] = "", + ["MaxParallelism"] = 0, + ["FailFast"] = false, + ["PerTestTimeout"] = "30s", + ["JUnitOutput"] = null, + ["Keep"] = false, + ["Verbose"] = false, + ["AllowEmpty"] = false, + ["LoggerName"] = "NpgsqlRestTest", + ["ResponseTempTable"] = new JsonObject + { + ["Name"] = "_response", + ["MultiNamePattern"] = "_response_{n}", + ["Columns"] = new JsonObject + { + ["Status"] = "status", + ["Body"] = "body", + ["ContentType"] = "content_type", + ["Headers"] = "headers", + ["IsSuccess"] = "is_success" + } + }, + ["Setup"] = new JsonArray(), + ["Teardown"] = new JsonArray() + }; + } + private static JsonObject GetTableFormatOptionsDefaults() { return new JsonObject diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 6e3ba10f..e84405cb 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -158,7 +158,7 @@ public static partial class ConfigSchemaGenerator ["Auth:PasskeyAuth:ClientAnalyticsIpKey"] = "The JSON key name used to add the client's IP address to the analytics data server-side.\nSet to null or empty string to disable IP address collection.", ["Auth:PasskeyAuth:StatusColumnName"] = "Column name configuration for database responses", ["Log"] = "Serilog settings", - ["Log:MinimalLevels"] = "See https://github.com/serilog/serilog/wiki/Configuration-Basics#minimum-level\nVerbose, Debug, Information, Warning, Error, Fatal.\nNote: NpgsqlRest logger applies to main application logger, which will, by default have the name defined in the ApplicationName setting.", + ["Log:MinimalLevels"] = "See https://github.com/serilog/serilog/wiki/Configuration-Basics#minimum-level\nVerbose, Debug, Information, Warning, Error, Fatal.\nSet a level to \"Off\" (aliases \"None\"/\"Silent\") to mute that logger entirely; use null (or omit it) to fall back to its built-in default.\nNote: NpgsqlRest logger applies to main application logger, which will, by default have the name defined in the ApplicationName setting.\nNpgsqlRestTest is the SQL test runner (--test) channel (see TestRunner:LoggerName): discovery/parsing at Debug, each query and HTTP call at Verbose, notices by severity.", ["Log:ToConsole"] = "Enable logging to console output.", ["Log:ConsoleMinimumLevel"] = "Minimum log level for console output: Verbose, Debug, Information, Warning, Error, Fatal.", ["Log:ToFile"] = "Enable logging to file system.", @@ -246,6 +246,21 @@ public static partial class ConfigSchemaGenerator ["Stats:TablesStatsPath"] = "Path for table statistics.\nReturns data from pg_stat_user_tables including tuple counts, sizes, scan counts, and vacuum info.", ["Stats:IndexesStatsPath"] = "Path for index statistics.\nReturns data from pg_stat_user_indexes including scan counts and index definitions.", ["Stats:ActivityPath"] = "Path for current database activity.\nReturns data from pg_stat_activity showing active sessions, queries, and wait events.\nSecurity Consideration: Shows currently running queries which may contain sensitive data.", + ["TestRunner"] = "SQL test runner. Invoked with the `--test` command-line flag (otherwise inert).\nDiscovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints in-process from an embedded `/* GET /path */` block (response captured into a temp table), and asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks.\nExit codes: 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found.", + ["TestRunner:FilePattern"] = "Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables discovery.\nCo-located convention: app.sql (endpoint) next to app.test.sql (test).", + ["TestRunner:MaxParallelism"] = "Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection.", + ["TestRunner:FailFast"] = "Stop scheduling new tests after the first failure/error (in-flight tests still finish).", + ["TestRunner:PerTestTimeout"] = "Per-test timeout. Accepts \"30s\", \"5m\", \"1h\", a plain number of seconds, or \"hh:mm:ss\". 0 disables.", + ["TestRunner:JUnitOutput"] = "Optional path to also write a JUnit XML report (console output is always printed).", + ["TestRunner:Keep"] = "Skip Teardown so a failed run's state can be inspected.", + ["TestRunner:Verbose"] = "Show captured `raise notice` output for all tests, not just failed/errored ones.", + ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", + ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", + ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", + ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", + ["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.", + ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra), then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group.\nEach entry: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } | { \"SqlFile\": \"...\" } | { \"Sql\": \"...\" }.", + ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command (e.g. docker down). The `Keep` option skips it.", ["CommandRetryOptions"] = "Command retry strategies and options for client and middleware commands.", ["CommandRetryOptions:Strategies:default:RetrySequenceSeconds"] = "Retry sequence in seconds. Accepts decimal numbers (0.25 is quarter of a second). The length of the array determines the maximum number of retries.", ["CommandRetryOptions:Strategies:default:ErrorCodes"] = "Error codes that will trigger a retry when executing a command. See https://www.postgresql.org/docs/current/errcodes-appendix.html", @@ -518,6 +533,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:SqlFileSource"] = "SQL file source for generating REST API endpoints from .sql files.", ["NpgsqlRest:SqlFileSource:Enabled"] = "Enable or disable SQL file source endpoints. Default is false.", ["NpgsqlRest:SqlFileSource:FilePattern"] = "Glob pattern for SQL files, e.g. \"sql/**/*.sql\", \"queries/*.sql\".\nSupports * (any chars), ** (recursive, any including /), ? (single char).\nEmpty string disables the feature.", + ["NpgsqlRest:SqlFileSource:SkipPattern"] = "Glob (same semantics as FilePattern) for files to EXCLUDE from endpoint discovery.\nDefault \"*.test.sql\" so co-located SQL test files (run by the test runner) are never exposed as endpoints. Empty string disables the exclusion.", ["NpgsqlRest:SqlFileSource:CommentsMode"] = "How comment annotations are processed for SQL file endpoints.\nPossible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag.\nDefault: OnlyAnnotated (OnlyWithHttpTag is a back-compat alias) — SQL files must contain an explicit HTTP annotation (e.g., '-- HTTP GET') to become endpoints.", ["NpgsqlRest:SqlFileSource:CommentScope"] = "Which comments in the SQL file to parse as annotations.\nPossible values: All (default — all comments), Header (only comments before the first statement).", ["NpgsqlRest:SqlFileSource:ErrorMode"] = "Behavior when a SQL file fails to parse or describe.\nPossible values: Exit (default — log error, exit process), Skip (log error, continue).", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 7e3cb377..f992f6f2 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -890,11 +890,14 @@ public static partial class ConfigSchemaGenerator // // See https://github.com/serilog/serilog/wiki/Configuration-Basics#minimum-level // Verbose, Debug, Information, Warning, Error, Fatal. + // Set a level to "Off" (aliases "None"/"Silent") to mute that logger entirely; use null (or omit it) to fall back to its built-in default. // Note: NpgsqlRest logger applies to main application logger, which will, by default have the name defined in the ApplicationName setting. + // NpgsqlRestTest is the SQL test runner (--test) channel (see TestRunner:LoggerName): discovery/parsing at Debug, each query and HTTP call at Verbose, notices by severity. // "MinimalLevels": { "NpgsqlRest": "Information", "NpgsqlRestClient": "Information", + "NpgsqlRestTest": "Information", "System": "Warning", "Microsoft": "Warning" }, @@ -1430,7 +1433,83 @@ public static partial class ConfigSchemaGenerator // "ActivityPath": "/stats/activity" }, - + + // + // SQL test runner. Invoked with the `--test` command-line flag (this section is otherwise inert). + // Discovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints + // in-process from an embedded `/* GET /path */` block (response captured into a temp table), and + // asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks. Exit codes: + // 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found. + // + "TestRunner": { + // + // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. + // Co-located convention: app.sql (endpoint) next to app.test.sql (test). + // + "FilePattern": "", + // + // Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection. + // + "MaxParallelism": 0, + // + // Stop scheduling new tests after the first failure/error (in-flight tests still finish). + // + "FailFast": false, + // + // Per-test timeout. Accepts "30s", "5m", "1h", a plain number of seconds, or "hh:mm:ss". 0 disables. + // + "PerTestTimeout": "30s", + // + // Optional path to also write a JUnit XML report (console output is always printed). + // + "JUnitOutput": null, + // + // Skip Teardown so a failed run's state can be inspected. + // + "Keep": false, + // + // Show captured `raise notice` output for all tests, not just failed/errored ones. + // + "Verbose": false, + // + // Treat "no tests discovered" as success (exit 0) instead of exit 4. + // + "AllowEmpty": false, + // + // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. + // Discovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. + // + "LoggerName": "NpgsqlRestTest", + // + // Per-HTTP-block temp table that captures the response. Each block gets its own fresh temp table + // (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. + // A file with ONE HTTP block uses "Name"; a file with 2+ blocks uses "MultiNamePattern" where {n} is + // the 1-based block ordinal (_response_1, _response_2, ...). Per-block override: `# @response `. + // A null/empty column name omits that column. + // + "ResponseTempTable": { + "Name": "_response", + "MultiNamePattern": "_response_{n}", + "Columns": { + "Status": "status", + "Body": "body", + "ContentType": "content_type", + "Headers": "headers", + "IsSuccess": "is_success" + } + }, + // + // Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra, e.g. docker), + // then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group. Each entry: + // { "Command": "...", "WorkingDirectory": "..." } | { "SqlFile": "..." } | { "Sql": "..." } + // + "Setup": [], + // + // Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command. `Keep` skips it. + // + "Teardown": [] + }, + // // Command retry strategies and options for client and middleware commands. // @@ -2917,6 +2996,12 @@ public static partial class ConfigSchemaGenerator // "FilePattern": "", // + // Glob (same semantics as FilePattern) for files to EXCLUDE from endpoint discovery. + // Default "*.test.sql" so co-located SQL test files (run by the test runner, see "TestRunner") + // are never exposed as endpoints. Empty string disables the exclusion. + // + "SkipPattern": "*.test.sql", + // // How comment annotations are processed for SQL file endpoints. // Possible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag. // OnlyAnnotated (default; OnlyWithHttpTag is a back-compat alias) requires an explicit diff --git a/NpgsqlRestClient/Out.cs b/NpgsqlRestClient/Out.cs index 6a5d95c2..f1c9c05a 100644 --- a/NpgsqlRestClient/Out.cs +++ b/NpgsqlRestClient/Out.cs @@ -46,6 +46,23 @@ public void Write(string line, ConsoleColor? color = null) } } + /// + /// Write a line wrapped in a raw ANSI SGR sequence (e.g. "\x1b[38;5;196m" for a 256-color foreground), + /// used when a specific color is needed that the 16-color API can't express. + /// Emits plain text (no escapes) when output is redirected, so piped/CI logs stay clean. + /// + public void LineAnsi(string line, string ansiSgr) + { + if (Console.IsOutputRedirected) + { + Console.WriteLine(line); + return; + } + Console.Write(ansiSgr); + Console.Write(line); + Console.WriteLine("\x1b[0m"); + } + public void JsonHighlight(string json) { if (Console.IsOutputRedirected) diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index f4096532..a10031b6 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -38,6 +38,7 @@ ("npgsqlrest --config-schema", "Output JSON Schema for appsettings.json. Syntax highlighted in terminal, plain JSON when piped."), ("npgsqlrest --annotations", "Output all supported comment annotations as JSON. Syntax highlighted in terminal, plain JSON when piped."), ("npgsqlrest [files...] --endpoints", "Connect to database, discover endpoints, output as JSON, then exit. Syntax highlighted in terminal, plain JSON when piped."), + ("npgsqlrest [files...] --test", "Run SQL test files (TestRunner config), then exit. Exit codes: 0 pass, 1 failures, 2 errors, 3 config error, 4 no tests."), (" ", " "), ("npgsqlrest --hash [value]", "Hash value with default hasher and print to console."), ("npgsqlrest --basic_auth [username] [password]", "Print out basic basic auth header value in format 'Authorization: Basic base64(username:password)'."), @@ -268,6 +269,7 @@ } bool endpointsMode = args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase)); +bool testMode = args.Any(a => string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase)); builder.BuildInstance(); builder.Instance.Services.AddRouting(); @@ -482,6 +484,30 @@ TableFormatHandlers = appInstance.CreateTableFormatHandlers(), }; +// SQL test runner: run Setup (Commands then SqlFile/Sql) BEFORE discovery so migrations can build the +// schema that UseNpgsqlRest then discovers. The runner sets options.AmbientConnectionAccessor in its ctor. +NpgsqlRestClient.Testing.TestRunner? testRunner = null; +if (testMode) +{ + // Test-mode invariants: the endpoint must never begin/commit on the test's connection, and caching + // must be off so a test never gets another test's cached response. + options.WrapInTransaction = false; + options.CacheOptions.DefaultRoutineCache = null; + options.CacheOptions.Profiles = null; + // Make the runner the single source of connection-notice logging, so notices from the endpoint + // pipeline and from the test's own SQL are logged identically (and once). Capture the user's setting, + // then disable the per-endpoint notice logging to avoid double-logging on the shared test connection. + var logTestNotices = options.LogConnectionNoticeEvents; + options.LogConnectionNoticeEvents = false; + + testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices); + if (await testRunner.SetupAsync() is false) + { + Environment.ExitCode = NpgsqlRestClient.Testing.TestRunner.ExitConfig; + return; + } +} + app.UseNpgsqlRest(options); if (endpointsMode) @@ -495,6 +521,13 @@ return; } +if (testMode) +{ + // Endpoints are now built; run the test files (then Teardown), and exit. + Environment.ExitCode = await testRunner!.RunAsync(EndpointCapture.Endpoints); + return; +} + if (builder.ExternalAuthConfig?.Enabled is true) { new ExternalAuth( diff --git a/NpgsqlRestClient/Testing/HttpFileRequestParser.cs b/NpgsqlRestClient/Testing/HttpFileRequestParser.cs new file mode 100644 index 00000000..e5939a60 --- /dev/null +++ b/NpgsqlRestClient/Testing/HttpFileRequestParser.cs @@ -0,0 +1,150 @@ +namespace NpgsqlRestClient.Testing; + +/// +/// Parses a single HTTP request out of a block-comment body, using a single-request subset of the +/// Microsoft .http syntax plus the test directives # @claim, # @expect-status, # @response. +/// Returns null when the block is not an HTTP request (ordinary SQL comment). +/// +public static class HttpFileRequestParser +{ + private static readonly string[] Methods = ["GET", "PUT", "POST", "DELETE"]; + + /// + /// Try to parse (the text between /* and */) as an HTTP request. + /// Returns null if the block's first non-blank/non-comment line is not a request line. + /// + public static HttpStep? TryParse(string commentBody, int lineNumber) + { + var lines = commentBody.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + // Find the request line: first line that is not blank and not a #/// comment line. + int idx = 0; + string? requestLine = null; + for (; idx < lines.Length; idx++) + { + var t = lines[idx].Trim(); + if (t.Length == 0) continue; + if (t.StartsWith('#') || t.StartsWith("//")) continue; + requestLine = t; + break; + } + if (requestLine is null) return null; + + if (!TryParseRequestLine(requestLine, out var method, out var path)) return null; + + idx++; // consume the request line + + var headers = new List<(string, string)>(); + var claims = new List<(string, string)>(); + int? expectStatus = null; + string? responseTable = null; + int bodyStart = -1; + + for (; idx < lines.Length; idx++) + { + var t = lines[idx].Trim(); + if (t.Length == 0) + { + bodyStart = idx + 1; // body begins after the blank line + break; + } + + string? directive = null; + if (t.StartsWith('#')) directive = t[1..].TrimStart(); + else if (t.StartsWith("//")) directive = t[2..].TrimStart(); + + if (directive is not null) + { + if (directive.StartsWith('@')) + { + ParseDirective(directive[1..].Trim(), claims, ref expectStatus, ref responseTable); + } + // plain comment line → ignore + continue; + } + + // Header: Name: Value + int colon = t.IndexOf(':'); + if (colon > 0) + { + headers.Add((t[..colon].Trim(), t[(colon + 1)..].Trim())); + } + // otherwise: stray line in the header region → ignore + } + + string? body = null; + if (bodyStart >= 0 && bodyStart < lines.Length) + { + var bodyText = string.Join('\n', lines[bodyStart..]).Trim(); + if (bodyText.Length > 0) body = bodyText; + } + + return new HttpStep(method, path, headers, claims, body, expectStatus, responseTable, lineNumber); + } + + // [HTTP] METHOD /path [HTTP/x]. Method required (GET|PUT|POST|DELETE); path must start with '/'; + // an optional trailing HTTP-version token is allowed but ignored; anything else → not a request. + private static bool TryParseRequestLine(string line, out string method, out string path) + { + method = ""; + path = ""; + var tokens = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return false; + + int ti = 0; + if (string.Equals(tokens[ti], "HTTP", StringComparison.OrdinalIgnoreCase)) + { + ti++; // optional leading HTTP keyword + } + if (ti >= tokens.Length) return false; + + var m = tokens[ti].ToUpperInvariant(); + if (Array.IndexOf(Methods, m) < 0) return false; + ti++; + + if (ti >= tokens.Length) return false; // method but no path + var p = tokens[ti]; + if (!p.StartsWith('/')) return false; // reject prose like "GET the data" + ti++; + + // At most one trailing token, which must be an HTTP-version; otherwise it's prose, not a request. + if (ti < tokens.Length) + { + if (!tokens[ti].StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase)) return false; + ti++; + if (ti < tokens.Length) return false; + } + + method = m; + path = p; + return true; + } + + private static void ParseDirective( + string directive, + List<(string, string)> claims, + ref int? expectStatus, + ref string? responseTable) + { + int sp = directive.IndexOfAny([' ', '\t']); + var keyword = sp < 0 ? directive : directive[..sp]; + var rest = sp < 0 ? "" : directive[(sp + 1)..].Trim(); + + if (string.Equals(keyword, "claim", StringComparison.OrdinalIgnoreCase)) + { + int eq = rest.IndexOf('='); + if (eq > 0) + { + claims.Add((rest[..eq].Trim(), rest[(eq + 1)..].Trim())); + } + } + else if (string.Equals(keyword, "expect-status", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(rest, out var code)) expectStatus = code; + } + else if (string.Equals(keyword, "response", StringComparison.OrdinalIgnoreCase)) + { + if (rest.Length > 0) responseTable = rest; + } + } +} diff --git a/NpgsqlRestClient/Testing/SqlTestFileParser.cs b/NpgsqlRestClient/Testing/SqlTestFileParser.cs new file mode 100644 index 00000000..bc8bc385 --- /dev/null +++ b/NpgsqlRestClient/Testing/SqlTestFileParser.cs @@ -0,0 +1,229 @@ +using System.Text; + +namespace NpgsqlRestClient.Testing; + +/// +/// Splits a .test.sql file into an ordered list of steps (SQL statements and embedded HTTP requests), +/// preserving interleaving. A char-by-char state machine handles ';' statement splitting, line/block +/// comments, single quotes (with '' escapes), and dollar-quoted strings / DO blocks (so semicolons inside +/// them don't split). A block comment whose first line is a request line becomes an ; +/// any other comment is ignored. +/// +public static class SqlTestFileParser +{ + private enum State { Normal, BlockComment, SingleQuote, DollarQuote } + + public static List Parse(string content) + { + var steps = new List(); + if (string.IsNullOrEmpty(content)) return steps; + + var state = State.Normal; + var stmt = new StringBuilder(); + var dollarTag = new StringBuilder(); + var blockComment = new StringBuilder(); + int blockDepth = 0; + int currentLine = 1; + int stmtStartLine = 1; + bool stmtHasContent = false; + int blockCommentStartLine = 1; + string lastToken = ""; + bool lastTokenIsWord = false; + bool isDoBlock = false; + + void MarkContent() + { + if (!stmtHasContent) + { + stmtHasContent = true; + stmtStartLine = currentLine; + } + } + + void FlushSql() + { + var text = stmt.ToString().Trim(); + if (text.Length > 0) + { + steps.Add(new SqlStep(text, isDoBlock, stmtStartLine)); + } + stmt.Clear(); + stmtHasContent = false; + isDoBlock = false; + lastToken = ""; + lastTokenIsWord = false; + } + + int i = 0; + int len = content.Length; + + while (i < len) + { + char c = content[i]; + switch (state) + { + case State.Normal: + // line comment: -- (skip to end of line, never an HTTP step) + if (c == '-' && i + 1 < len && content[i + 1] == '-') + { + i += 2; + while (i < len && content[i] != '\n') i++; + continue; + } + // block comment: /* + if (c == '/' && i + 1 < len && content[i + 1] == '*') + { + state = State.BlockComment; + blockDepth = 1; + blockComment.Clear(); + blockCommentStartLine = currentLine; + i += 2; + continue; + } + // single quote + if (c == '\'') + { + state = State.SingleQuote; + stmt.Append(c); + MarkContent(); + lastToken = ""; + lastTokenIsWord = false; + i++; + continue; + } + // dollar quote: $tag$ or $$ + if (c == '$') + { + int tagStart = i; + i++; + while (i < len && (char.IsLetterOrDigit(content[i]) || content[i] == '_')) i++; + if (i < len && content[i] == '$') + { + dollarTag.Clear(); + dollarTag.Append(content, tagStart, i - tagStart + 1); + state = State.DollarQuote; + if (lastTokenIsWord && lastToken.Length == 2 + && (lastToken[0] is 'd' or 'D') && (lastToken[1] is 'o' or 'O')) + { + isDoBlock = true; + } + stmt.Append(content, tagStart, i - tagStart + 1); + MarkContent(); + lastToken = ""; + lastTokenIsWord = false; + i++; + continue; + } + // not a valid dollar quote: treat $ + run as normal text + stmt.Append(content, tagStart, i - tagStart); + MarkContent(); + lastToken = ""; + lastTokenIsWord = false; + continue; + } + // statement separator + if (c == ';') + { + FlushSql(); + i++; + continue; + } + // word token (for DO-block detection) + if (char.IsLetter(c) || c == '_') + { + int ws = i; + while (i < len && (char.IsLetterOrDigit(content[i]) || content[i] == '_')) i++; + stmt.Append(content, ws, i - ws); + MarkContent(); + lastToken = content.Substring(ws, i - ws); + lastTokenIsWord = true; + continue; + } + // whitespace / other + if (c == '\n') currentLine++; + stmt.Append(c); + if (!char.IsWhiteSpace(c)) + { + MarkContent(); + lastToken = ""; + lastTokenIsWord = false; + } + i++; + break; + + case State.BlockComment: + if (c == '/' && i + 1 < len && content[i + 1] == '*') + { + blockDepth++; + blockComment.Append("/*"); + i += 2; + continue; + } + if (c == '*' && i + 1 < len && content[i + 1] == '/') + { + blockDepth--; + if (blockDepth == 0) + { + state = State.Normal; + i += 2; + var http = HttpFileRequestParser.TryParse(blockComment.ToString(), blockCommentStartLine); + if (http is not null) + { + FlushSql(); // emit any pending SQL before the HTTP step + steps.Add(http); + } + // else: ordinary comment → ignored + continue; + } + blockComment.Append("*/"); + i += 2; + continue; + } + if (c == '\n') currentLine++; + blockComment.Append(c); + i++; + break; + + case State.SingleQuote: + stmt.Append(c); + if (c == '\n') currentLine++; + if (c == '\'') + { + if (i + 1 < len && content[i + 1] == '\'') + { + stmt.Append('\''); + i += 2; + continue; + } + state = State.Normal; + } + i++; + break; + + case State.DollarQuote: + stmt.Append(c); + if (c == '\n') currentLine++; + if (c == '$' && i + dollarTag.Length <= len) + { + bool match = true; + for (int j = 0; j < dollarTag.Length; j++) + { + if (content[i + j] != dollarTag[j]) { match = false; break; } + } + if (match) + { + stmt.Append(content, i + 1, dollarTag.Length - 1); + i += dollarTag.Length; + state = State.Normal; + continue; + } + } + i++; + break; + } + } + + FlushSql(); + return steps; + } +} diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs new file mode 100644 index 00000000..2e55446d --- /dev/null +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -0,0 +1,708 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Security.Claims; +using System.Text; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlRest; + +namespace NpgsqlRestClient.Testing; + +/// +/// SQL test runner (`--test`). Discovers *.test.sql files and runs each on its own non-pooled connection, +/// invoking endpoints in-process (via on the test's ambient connection) and +/// asserting on the captured response. See scrap/test-runner-plan for the full design. +/// +public sealed class TestRunner +{ + // Failure/error color: ANSI 256-color 196 — the same red Serilog's Code theme uses (truer than the + // 16-color ConsoleColor.Red, which renders orange in some themes). Emitted only to a real terminal. + private const string AnsiFail = "\x1b[38;5;196m"; + + public const int ExitPass = 0; + public const int ExitFailures = 1; + public const int ExitErrors = 2; + public const int ExitConfig = 3; + public const int ExitNoTests = 4; + + // Per-async-flow ambient connection: each parallel test file sets this so the endpoint pipeline + // (NpgsqlRestOptions.AmbientConnectionAccessor) runs on that file's connection/transaction. + private static readonly AsyncLocal Ambient = new(); + + private readonly NpgsqlRestOptions _rest; + private readonly TestRunnerOptions _opt; + private readonly string _connString; + private readonly ILogger? _log; + private readonly bool _logNotices; + private readonly Out _out = new(); + private volatile bool _failFast; + + private enum Outcome { Pass, Fail, Error } + + // Result of executing one step. Emit=false marks an arrange/act step that is not a reported test (it + // only becomes a result when it errors). Name/Message describe the assertion when Emit=true. + private readonly record struct StepResult(bool Emit, Outcome Outcome, string? Message, string? Name); + + // One reported test = one assertion: a boolean-returning SELECT, a DO block (passes unless it raises, + // e.g. via ASSERT), or an HTTP step with `# @expect-status`. Errors from any step are also recorded + // here so they surface and count. + private sealed class AssertionResult + { + public required string Name { get; init; } + public int? Line { get; init; } + public Outcome Outcome { get; init; } + public string? Message { get; init; } + public string? Sql { get; init; } + } + + // A test file groups its assertions; the file's outcome is the worst of them (Error > Fail > Pass). + // Execution is fail-fast: assertions after the first failure/error in a file are not run (so not listed). + private sealed class FileResult + { + public required string File { get; init; } + public long ElapsedMs { get; set; } + public List Assertions { get; } = []; + public List Notices { get; set; } = []; + + public Outcome Outcome => + Assertions.Any(a => a.Outcome == Outcome.Error) ? Outcome.Error : + Assertions.Any(a => a.Outcome == Outcome.Fail) ? Outcome.Fail : + Outcome.Pass; + } + + public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConnectionString, ILogger? logger, bool logConnectionNotices = false) + { + _rest = rest; + _opt = opt; + // Always non-pooled: a fresh physical session per test (no temp-table / GUC / prepared-statement carryover). + _connString = new NpgsqlConnectionStringBuilder(baseConnectionString) { Pooling = false }.ConnectionString; + _log = logger; + _logNotices = logConnectionNotices; + // Wire the ambient accessor once; null when no test flow is active → zero effect on discovery/normal ops. + _rest.AmbientConnectionAccessor = () => Ambient.Value; + } + + /// Runs before endpoint discovery: collision check + Setup steps. Returns false on failure (logged, teardown attempted). + public async Task SetupAsync(CancellationToken ct = default) + { + try + { + // Response-table names are validated and created per HTTP block (no pre-run permanent-table + // collision check needed: writes are pg_temp-qualified and CREATE TEMP TABLE (no IF NOT EXISTS) + // fails loudly on a real duplicate). + _log?.LogDebug("setup: {Commands} command(s), {Sql} sql step(s)", + _opt.Setup.Count(s => s.IsCommand), _opt.Setup.Count(s => !s.IsCommand)); + + // Commands first (provision infra), then SqlFile/Sql (migrations/seed), in declared order within each group. + foreach (var step in _opt.Setup.Where(s => s.IsCommand)) + { + await RunCommandAsync(step, ct); + } + await using (var conn = new NpgsqlConnection(_connString)) + { + await conn.OpenAsync(ct); + foreach (var step in _opt.Setup.Where(s => !s.IsCommand)) + { + await RunSqlStepAsync(conn, step, ct); + } + } + return true; + } + catch (Exception ex) + { + _out.Line($"Test runner setup failed: {ex.Message}", ConsoleColor.Red); + _log?.LogError(ex, "Test runner setup failed"); + await TeardownAsync(ct); + return false; + } + } + + /// Runs after endpoint discovery: executes the test files and (always) Teardown. Returns the process exit code. + public async Task RunAsync(RoutineEndpoint[] endpoints, CancellationToken ct = default) + { + try + { + var files = DiscoverFiles(); + _log?.LogDebug("discovered {Count} test file(s) matching {Pattern}", files.Count, _opt.FilePattern); + if (files.Count == 0) + { + _out.Line("Test runner: no test files matched FilePattern.", ConsoleColor.Yellow); + return _opt.AllowEmpty ? ExitPass : ExitNoTests; + } + + var lookup = BuildEndpointLookup(endpoints); + var results = new ConcurrentBag(); + int dop = _opt.MaxParallelism > 0 ? _opt.MaxParallelism : Environment.ProcessorCount; + + // Console header stays a clean human report (just the file count, like other test runners). The + // parallelism is a diagnostic → log channel at Debug, not the always-on console line. + _out.Line($"NpgsqlRest test runner — {files.Count} file(s)", ConsoleColor.Cyan); + _log?.LogDebug("running {Count} test file(s) at degree of parallelism {Parallelism}", files.Count, dop); + + await Parallel.ForEachAsync( + files, + new ParallelOptions { MaxDegreeOfParallelism = dop, CancellationToken = ct }, + async (file, _) => + { + if (_failFast) return; // stop scheduling new work after the first failure (in-flight finish) + var r = await RunFileAsync(file, lookup, ct); + results.Add(r); + if (_opt.FailFast && r.Outcome != Outcome.Pass) _failFast = true; + }); + + var ordered = results.OrderBy(r => r.File, StringComparer.Ordinal).ToList(); + ReportConsole(ordered); + if (!string.IsNullOrWhiteSpace(_opt.JUnitOutput)) + { + WriteJUnit(ordered, _opt.JUnitOutput!); + } + + if (ordered.Any(r => r.Outcome == Outcome.Error)) return ExitErrors; + if (ordered.Any(r => r.Outcome == Outcome.Fail)) return ExitFailures; + return ExitPass; + } + finally + { + await TeardownAsync(ct); + } + } + + private async Task RunFileAsync(string file, IReadOnlyDictionary lookup, CancellationToken runCt) + { + var sw = Stopwatch.StartNew(); + var result = new FileResult { File = file }; + var notices = new List(); + NpgsqlConnection? conn = null; + var contextName = Path.GetRelativePath(Environment.CurrentDirectory, file); + + using var perTestCts = CancellationTokenSource.CreateLinkedTokenSource(runCt); + if (_opt.PerTestTimeout > TimeSpan.Zero) perTestCts.CancelAfter(_opt.PerTestTimeout); + var ct = perTestCts.Token; + int timeoutSecs = _opt.PerTestTimeout > TimeSpan.Zero ? (int)Math.Ceiling(_opt.PerTestTimeout.TotalSeconds) : 0; + + try + { + conn = new NpgsqlConnection(_connString); + conn.Notice += (_, e) => + { + // Route notices through the test runner's own log channel, tagged with this test file. The + // log level follows the notice severity (info/notice => Information, warning => Warning, …); + // the channel's MinimalLevel governs visibility. Also capture per-test for the console report. + if (_logNotices && _log is not null) + NpgsqlRestLogger.LogConnectionNotice(_log, e.Notice, _rest.LogConnectionNoticeEventsMode, contextName); + lock (notices) notices.Add(e.Notice.MessageText); + }; + await conn.OpenAsync(ct); + Ambient.Value = conn; + + var steps = SqlTestFileParser.Parse(await File.ReadAllTextAsync(file, ct)); + + int httpTotal = steps.Count(s => s is HttpStep); + int httpOrdinal = 0; + _log?.LogDebug("parsed {File}: {Steps} step(s), {Http} HTTP block(s)", contextName, steps.Count, httpTotal); + foreach (var step in steps) + { + StepResult sr; + if (step is SqlStep sql) + { + sr = await ExecuteSqlStepAsync(conn, sql, timeoutSecs, ct); + } + else if (step is HttpStep http) + { + httpOrdinal++; + sr = await InvokeHttpStepAsync(conn, http, ResolveResponseTable(http, httpOrdinal, httpTotal), lookup, ct); + } + else + { + continue; + } + + if (sr.Emit) + { + result.Assertions.Add(new AssertionResult + { + Name = sr.Name ?? DefaultAssertionName(step), + Line = step.LineNumber, + Outcome = sr.Outcome, + Message = sr.Message, + Sql = step is SqlStep s ? s.Text : null, + }); + } + // Fail-fast within a file: a failed/errored step (a failing DO-block assert aborts the PG + // transaction anyway) stops execution, so later assertions in this file are not run. + if (sr.Outcome is Outcome.Fail or Outcome.Error) break; + } + } + catch (OperationCanceledException) when (perTestCts.IsCancellationRequested && !runCt.IsCancellationRequested) + { + result.Assertions.Add(TimeoutAssertion()); + } + catch (PostgresException pg) when (pg.SqlState == "57014") + { + result.Assertions.Add(TimeoutAssertion()); + } + catch (Exception ex) + { + result.Assertions.Add(new AssertionResult { Name = "execution error", Outcome = Outcome.Error, Message = ex.Message }); + } + finally + { + // No explicit ROLLBACK: the connection is non-pooled, so DisposeAsync physically closes it and + // the server aborts any still-open transaction — that is the safety net for a test that didn't + // roll back itself. An explicit ROLLBACK here would also raise PG warning 25P01 + // ("there is no transaction in progress") for the common case where the test already rolled + // back or never opened a transaction. + Ambient.Value = null; + if (conn is not null) await conn.DisposeAsync(); + } + + result.ElapsedMs = sw.ElapsedMilliseconds; + result.Notices = notices; + _log?.LogDebug("{File}: {Outcome} ({Count} assertion(s), {Ms}ms)", + contextName, result.Outcome, result.Assertions.Count, result.ElapsedMs); + return result; + + AssertionResult TimeoutAssertion() => new() + { + Name = "timed out", + Outcome = Outcome.Error, + Message = $"timed out after {_opt.PerTestTimeout.TotalSeconds:0}s", + }; + } + + private async Task ExecuteSqlStepAsync( + NpgsqlConnection conn, SqlStep step, int timeoutSecs, CancellationToken ct) + { + _log?.LogTrace("sql (line {Line}): {Sql}", step.LineNumber, step.Text.Trim()); + try + { + await using var cmd = new NpgsqlCommand(step.Text, conn); + if (timeoutSecs > 0) cmd.CommandTimeout = timeoutSecs; + await using var reader = await cmd.ExecuteReaderAsync(ct); + + // Boolean first column => assertion (first row only; 0 rows = pass). The optional second column + // is the human-readable assertion name/message. + if (reader.FieldCount >= 1 && reader.GetFieldType(0) == typeof(bool)) + { + string? desc = null; + bool pass = true; + if (await reader.ReadAsync(ct)) + { + pass = !reader.IsDBNull(0) && reader.GetBoolean(0); + desc = reader.FieldCount >= 2 && !reader.IsDBNull(1) ? reader.GetValue(1)?.ToString() : null; + } + return pass + ? new StepResult(true, Outcome.Pass, null, desc) + : new StepResult(true, Outcome.Fail, desc ?? $"assertion failed: {FirstLine(step.Text)}", desc); + } + // A DO block produces no result set; it counts as an assertion that passes unless it raised + // (e.g. ASSERT => P0004, caught below). Any other statement is arrange/act, not a reported test. + return step.IsDoBlock + ? new StepResult(true, Outcome.Pass, null, null) + : new StepResult(false, Outcome.Pass, null, null); + } + catch (PostgresException pg) + { + if (pg.SqlState == "P0004") return new StepResult(true, Outcome.Fail, pg.MessageText, null); // assert + if (pg.SqlState == "57014") throw; // timeout — let RunFileAsync classify + return new StepResult(true, Outcome.Error, $"{pg.SqlState}: {pg.MessageText}", null); + } + } + + private async Task InvokeHttpStepAsync( + NpgsqlConnection conn, HttpStep step, string responseTable, IReadOnlyDictionary lookup, CancellationToken ct) + { + var httpName = $"{step.Method} {step.Path}"; + var pathOnly = StripQuery(step.Path); + if (lookup.TryGetValue($"{step.Method} {pathOnly}", out var ep)) + { + if (ep.SseEventsPath is not null || ep.SsePublishEnabled) + return new StepResult(true, Outcome.Error, $"SSE endpoints are not supported in test mode: {httpName}", httpName); + if (ep.Upload) + return new StepResult(true, Outcome.Error, $"upload endpoints are not supported in test mode: {httpName}", httpName); + if (ep.IsProxy) + { + var host = ep.ProxyHost ?? _rest.ProxyOptions?.Host; + if (host is not null && !host.StartsWith('/')) + return new StepResult(true, Outcome.Error, $"outbound proxy/HTTP-type endpoints are disallowed in test mode: {httpName}", httpName); + } + } + + var user = BuildPrincipal(step.Claims); + + Dictionary? headers = null; + string? contentType = null; + if (step.Headers.Count > 0) + { + headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, value) in step.Headers) + { + headers[name] = value; + if (string.Equals(name, "Content-Type", StringComparison.OrdinalIgnoreCase)) contentType = value; + } + } + + Ambient.Value = conn; // ensure this file's connection is used by the endpoint pipeline + _log?.LogTrace("http {Method} {Path} (line {Line})", step.Method, step.Path, step.LineNumber); + var response = await RoutineInvoker.InvokeAsync(step.Method, step.Path, headers, step.Body, contentType, user, ct); + _log?.LogTrace("http {Method} {Path} → {Status}, captured into temp table \"{Table}\"", + step.Method, step.Path, response.StatusCode, responseTable); + + await WriteResponseAsync(conn, responseTable, response, ct); + + // An HTTP block is a reported assertion only when it carries `# @expect-status`; otherwise it is an + // act step (capture the response into the temp table) and produces no test of its own. + if (step.ExpectStatus.HasValue) + { + var name = $"{httpName} (expect {step.ExpectStatus.Value})"; + return response.StatusCode == step.ExpectStatus.Value + ? new StepResult(true, Outcome.Pass, null, name) + : new StepResult(true, Outcome.Fail, $"expected status {step.ExpectStatus.Value} but got {response.StatusCode} for {httpName}", name); + } + return new StepResult(false, Outcome.Pass, null, null); + } + + private async Task WriteResponseAsync(NpgsqlConnection conn, string table, RoutineInvokeResult response, CancellationToken ct) + { + if (!ValidateIdentifier(table)) + throw new InvalidOperationException($"invalid response table name '{table}'"); + + var c = _opt.ResponseTempTable.Columns; + var cols = new List<(string Name, string Type, object? Value, bool Jsonb)>(); + if (!string.IsNullOrWhiteSpace(c.Status)) cols.Add((c.Status!, "int", response.StatusCode, false)); + if (!string.IsNullOrWhiteSpace(c.Body)) cols.Add((c.Body!, "text", (object?)response.Body ?? DBNull.Value, false)); + if (!string.IsNullOrWhiteSpace(c.ContentType)) cols.Add((c.ContentType!, "text", (object?)response.ContentType ?? DBNull.Value, false)); + if (!string.IsNullOrWhiteSpace(c.Headers)) cols.Add((c.Headers!, "jsonb", (object?)response.Headers ?? DBNull.Value, true)); + if (!string.IsNullOrWhiteSpace(c.IsSuccess)) cols.Add((c.IsSuccess!, "boolean", response.IsSuccess, false)); + if (cols.Count == 0) + throw new InvalidOperationException("ResponseTempTable.Columns has no columns enabled."); + + foreach (var col in cols) + { + if (!ValidateIdentifier(col.Name)) + throw new InvalidOperationException($"invalid response column name '{col.Name}'"); + } + + // CREATE TEMP TABLE (no IF NOT EXISTS): each HTTP block gets its own fresh table, so a name clash + // (e.g. a duplicate `# @response` name) fails the test loudly. TEMP forces pg_temp; the INSERT is + // pg_temp-qualified so it can never touch a permanent table. (Client runs with Npgsql SQL-rewriting + // disabled → one statement per command, positional params.) + var defs = string.Join(", ", cols.Select(col => $"\"{col.Name}\" {col.Type}")); + await using (var create = new NpgsqlCommand($"create temp table \"{table}\" ({defs})", conn)) + { + await create.ExecuteNonQueryAsync(ct); + } + + var colList = string.Join(", ", cols.Select(col => $"\"{col.Name}\"")); + var valList = string.Join(", ", cols.Select((col, i) => col.Jsonb ? $"${i + 1}::jsonb" : $"${i + 1}")); + await using var ins = new NpgsqlCommand($"insert into pg_temp.\"{table}\" ({colList}) values ({valList})", conn); + foreach (var col in cols) + { + ins.Parameters.AddWithValue(col.Value ?? DBNull.Value); + } + await ins.ExecuteNonQueryAsync(ct); + } + + // The temp-table name for an HTTP block: explicit `# @response` wins; otherwise a file with a single + // HTTP block uses Name (`_response`), and a file with 2+ blocks uses MultiNamePattern with {n} = the + // 1-based block ordinal (`_response_1`, `_response_2`, …). + private string ResolveResponseTable(HttpStep http, int ordinal, int httpTotal) + { + if (!string.IsNullOrWhiteSpace(http.ResponseTable)) return http.ResponseTable!.Trim(); + if (httpTotal <= 1) return _opt.ResponseTempTable.Name; + return _opt.ResponseTempTable.MultiNamePattern + .Replace("{n}", ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private static ClaimsPrincipal? BuildPrincipal(IReadOnlyList<(string Name, string Value)> claims) + { + if (claims.Count == 0) return null; // anonymous → 401 on protected endpoints + var identity = new ClaimsIdentity(authenticationType: "TestRunner"); // non-null auth type => IsAuthenticated + foreach (var (name, value) in claims) + { + identity.AddClaim(new Claim(name, value)); + } + return new ClaimsPrincipal(identity); + } + + // -------- Setup/Teardown step execution -------- + + private async Task RunCommandAsync(TestSetupStep step, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = string.IsNullOrWhiteSpace(step.WorkingDirectory) + ? Environment.CurrentDirectory + : Path.GetFullPath(step.WorkingDirectory), + }; + psi.ArgumentList.Add(OperatingSystem.IsWindows() ? "/c" : "-c"); + psi.ArgumentList.Add(step.Command!); + + _log?.LogDebug("setup/teardown command: {Command}", step.Command); + using var proc = new Process { StartInfo = psi }; + proc.Start(); + var stdout = await proc.StandardOutput.ReadToEndAsync(ct); + var stderr = await proc.StandardError.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + if (!string.IsNullOrWhiteSpace(stdout)) _log?.LogDebug("command output: {Out}", stdout.Trim()); + if (proc.ExitCode != 0) + { + throw new InvalidOperationException( + $"command failed (exit {proc.ExitCode}): {step.Command}{(string.IsNullOrWhiteSpace(stderr) ? "" : "\n" + stderr.Trim())}"); + } + } + + private async Task RunSqlStepAsync(NpgsqlConnection conn, TestSetupStep step, CancellationToken ct) + { + string sql; + if (!string.IsNullOrWhiteSpace(step.SqlFile)) + { + sql = await File.ReadAllTextAsync(Path.GetFullPath(step.SqlFile), ct); + } + else + { + sql = step.Sql ?? ""; + } + if (string.IsNullOrWhiteSpace(sql)) return; + _log?.LogDebug("setup/teardown sql: {Source}", step.SqlFile ?? "(inline)"); + // Split into individual statements (SQL-rewriting is disabled → one statement per command). + // Reuses the test-file parser; DO blocks / dollar-quoted bodies stay whole. + foreach (var parsed in SqlTestFileParser.Parse(sql)) + { + if (parsed is SqlStep s) + { + _log?.LogTrace("sql: {Sql}", s.Text.Trim()); + await using var cmd = new NpgsqlCommand(s.Text, conn); + await cmd.ExecuteNonQueryAsync(ct); + } + } + } + + private async Task TeardownAsync(CancellationToken ct) + { + if (_opt.Keep || _opt.Teardown.Count == 0) return; + _log?.LogDebug("teardown: {Steps} step(s)", _opt.Teardown.Count); + try + { + await using var conn = new NpgsqlConnection(_connString); + await conn.OpenAsync(ct); + foreach (var step in _opt.Teardown.Where(s => !s.IsCommand)) // SQL first + { + try { await RunSqlStepAsync(conn, step, ct); } + catch (Exception ex) { _log?.LogWarning(ex, "teardown SQL step failed"); } + } + } + catch (Exception ex) { _log?.LogWarning(ex, "teardown connection failed"); } + + foreach (var step in _opt.Teardown.Where(s => s.IsCommand)) // commands last (e.g. docker down) + { + try { await RunCommandAsync(step, ct); } + catch (Exception ex) { _log?.LogWarning(ex, "teardown command failed"); } + } + } + + // -------- discovery / reporting / helpers -------- + + private List DiscoverFiles() + { + var pattern = _opt.FilePattern; + if (string.IsNullOrWhiteSpace(pattern)) return []; + + int firstWildcard = pattern.IndexOfAny(['*', '?']); + if (firstWildcard < 0) + { + return File.Exists(pattern) ? [Path.GetFullPath(pattern)] : []; + } + + int lastSlash = pattern.LastIndexOf('/', firstWildcard); + string baseDir = lastSlash >= 0 ? pattern[..lastSlash] : "."; + if (!Directory.Exists(baseDir)) return []; + + var searchOption = pattern.Contains("**") ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var result = new List(); + foreach (var file in Directory.EnumerateFiles(baseDir, "*", searchOption)) + { + if (Parser.IsPatternMatch(file.Replace('\\', '/'), pattern)) + { + result.Add(file); + } + } + return result; + } + + private static Dictionary BuildEndpointLookup(RoutineEndpoint[] endpoints) + { + var d = new Dictionary(endpoints.Length, StringComparer.OrdinalIgnoreCase); + foreach (var ep in endpoints) + { + d[$"{ep.Method} {ep.Path}"] = ep; + } + return d; + } + + // Print the failing statement under the message (dimmed). Capped to its first line unless --verbose. + private void WriteFailingSql(string? sql) + { + if (string.IsNullOrWhiteSpace(sql)) return; + var text = sql.Trim(); + if (_opt.Verbose) + { + _out.Line(" " + text.Replace("\n", "\n "), ConsoleColor.DarkGray); + return; + } + int nl = text.IndexOf('\n'); + var line = (nl >= 0 ? text[..nl] : text).TrimEnd(); + bool more = nl >= 0 || line.Length > 120; + if (line.Length > 120) line = line[..120]; + _out.Line($" {line}{(more ? " …" : "")}", ConsoleColor.DarkGray); + } + + private void ReportConsole(List results) + { + // Counts are at assertion granularity (each boolean SELECT / DO block / `# @expect-status` HTTP + // step is one test); files are the grouping unit. A file is fail-fast, so on failure the assertions + // listed are those that ran up to and including the first failure. + int passed = 0, failed = 0, errored = 0, totalAssertions = 0; + foreach (var fr in results) + { + var rel = Path.GetRelativePath(Environment.CurrentDirectory, fr.File); + int aPass = fr.Assertions.Count(a => a.Outcome == Outcome.Pass); + passed += aPass; + failed += fr.Assertions.Count(a => a.Outcome == Outcome.Fail); + errored += fr.Assertions.Count(a => a.Outcome == Outcome.Error); + totalAssertions += fr.Assertions.Count; + + if (fr.Assertions.Count == 0) + { + // The file ran but contained no recognizable assertion — surface it rather than count a pass. + _out.Line($"PASS {rel} (no assertions, {fr.ElapsedMs}ms)", ConsoleColor.Yellow); + } + else if (fr.Outcome == Outcome.Pass) + { + _out.Line($"PASS {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)", ConsoleColor.Green); + if (_opt.Verbose) + foreach (var a in fr.Assertions) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); + } + else + { + var label = fr.Outcome == Outcome.Error ? "ERROR" : "FAIL "; + _out.LineAnsi($"{label} {rel} ({fr.ElapsedMs}ms)", AnsiFail); + foreach (var a in fr.Assertions) + { + if (a.Outcome == Outcome.Pass) + { + if (_opt.Verbose) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); + continue; + } + var loc = a.Line is null ? "" : $" [{rel}:{a.Line}]"; + _out.LineAnsi($" ✗ {a.Name}{loc}", AnsiFail); + if (!string.IsNullOrWhiteSpace(a.Message) && a.Message != a.Name) _out.LineAnsi($" {a.Message}", AnsiFail); + WriteFailingSql(a.Sql); + } + } + + if (fr.Notices.Count > 0 && (_opt.Verbose || fr.Outcome != Outcome.Pass)) + foreach (var n in fr.Notices) _out.Line($" notice: {n}", ConsoleColor.DarkGray); + } + + var summary = $"\n{passed} passed, {failed} failed, {errored} error(s) — {totalAssertions} assertion{(totalAssertions == 1 ? "" : "s")} in {results.Count} file{(results.Count == 1 ? "" : "s")}"; + if (failed + errored == 0) _out.Line(summary, ConsoleColor.Green); + else _out.LineAnsi(summary, AnsiFail); + } + + private static void WriteJUnit(List results, string path) + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + // One per assertion (classname = file), so CI tools count individual tests. A file with + // no assertions still appears as a single skipped testcase. Notices are attached to failing cases. + int tests = results.Sum(f => Math.Max(f.Assertions.Count, 1)); + int failures = results.Sum(f => f.Assertions.Count(a => a.Outcome == Outcome.Fail)); + int errors = results.Sum(f => f.Assertions.Count(a => a.Outcome == Outcome.Error)); + int skipped = results.Count(f => f.Assertions.Count == 0); + + var suite = new XElement("testsuite", + new XAttribute("name", "npgsqlrest"), + new XAttribute("tests", tests), + new XAttribute("failures", failures), + new XAttribute("errors", errors), + new XAttribute("skipped", skipped), + new XAttribute("time", (results.Sum(r => r.ElapsedMs) / 1000.0).ToString("0.000", inv))); + + foreach (var fr in results) + { + // Spread the file's wall-clock time evenly across its assertions for per-case timing. + double caseTime = fr.Assertions.Count > 0 ? fr.ElapsedMs / 1000.0 / fr.Assertions.Count : fr.ElapsedMs / 1000.0; + string caseTimeStr = caseTime.ToString("0.000", inv); + + if (fr.Assertions.Count == 0) + { + var empty = new XElement("testcase", + new XAttribute("name", "(no assertions)"), + new XAttribute("classname", fr.File), + new XAttribute("time", caseTimeStr)); + empty.Add(new XElement("skipped")); + suite.Add(empty); + continue; + } + + foreach (var a in fr.Assertions) + { + var tc = new XElement("testcase", + new XAttribute("name", a.Name), + new XAttribute("classname", fr.File), + new XAttribute("time", caseTimeStr)); + if (a.Outcome == Outcome.Fail) + tc.Add(new XElement("failure", new XAttribute("message", a.Message ?? "assertion failed"), $"{fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); + else if (a.Outcome == Outcome.Error) + tc.Add(new XElement("error", new XAttribute("message", a.Message ?? "error"), $"{fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); + if (a.Outcome != Outcome.Pass && fr.Notices.Count > 0) + tc.Add(new XElement("system-out", string.Join('\n', fr.Notices))); + suite.Add(tc); + } + } + + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + new XDocument(new XDeclaration("1.0", "utf-8", null), suite).Save(path); + } + + private static string StripQuery(string path) + { + int q = path.IndexOf('?'); + return q >= 0 ? path[..q] : path; + } + + private static string FirstLine(string text) + { + int nl = text.IndexOf('\n'); + var line = (nl >= 0 ? text[..nl] : text).Trim(); + return line.Length > 80 ? line[..80] + "…" : line; + } + + // Fallback display name when a step has no inherent label (a boolean SELECT without a description column, + // a DO block, or a non-`# @expect-status` HTTP step that errored). + private static string DefaultAssertionName(TestStep step) => step switch + { + SqlStep { IsDoBlock: true } => $"assert block (line {step.LineNumber})", + SqlStep sql => FirstLine(sql.Text), + HttpStep http => $"{http.Method} {http.Path}", + _ => $"step (line {step.LineNumber})", + }; + + // Valid unquoted/quoted SQL identifier: [A-Za-z_][A-Za-z0-9_]* (prevents injection in table/column DDL). + private static bool ValidateIdentifier(string? id) + { + if (string.IsNullOrEmpty(id)) return false; + if (!(char.IsLetter(id[0]) || id[0] == '_')) return false; + for (int i = 1; i < id.Length; i++) + { + if (!(char.IsLetterOrDigit(id[i]) || id[i] == '_')) return false; + } + return true; + } +} diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs new file mode 100644 index 00000000..2ccf7a55 --- /dev/null +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -0,0 +1,83 @@ +namespace NpgsqlRestClient.Testing; + +/// Options for the SQL test runner (--test). Bound from the top-level "TestRunner" config section. +public class TestRunnerOptions +{ + /// Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables the runner. + public string FilePattern { get; set; } = ""; + + /// Max concurrent test files. 0 => Environment.ProcessorCount. + public int MaxParallelism { get; set; } = 0; + + /// Stop scheduling new tests after the first failure/error (in-flight tests still finish). + public bool FailFast { get; set; } = false; + + /// Per-test timeout. Zero or negative => no timeout. + public TimeSpan PerTestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Optional path to also write a JUnit XML report (console is always printed). + public string? JUnitOutput { get; set; } = null; + + /// Skip Teardown so a failed run's state can be inspected. + public bool Keep { get; set; } = false; + + /// Show captured `raise notice` output for all tests, not just failed/errored ones. + public bool Verbose { get; set; } = false; + + /// Zero tests discovered => exit 0 instead of 4. + public bool AllowEmpty { get; set; } = false; + + /// + /// SourceContext name for the runner's own log channel — discovery/parsing at Debug, each query and HTTP + /// invocation at Verbose, captured `raise notice` by its severity. Set its level independently via + /// Log:MinimalLevels (defaults to Information when absent). The console PASS/FAIL report is separate. + /// + public string LoggerName { get; set; } = "NpgsqlRestTest"; + + public ResponseTempTableOptions ResponseTempTable { get; set; } = new(); + + /// Run-once setup, before endpoint discovery. Commands run first, then SqlFile/Sql (declared order within each group). + public List Setup { get; set; } = []; + + /// Run-once teardown, always (best-effort). Reverse: SqlFile/Sql first, then Commands. + public List Teardown { get; set; } = []; +} + +/// Response temp-table configuration. Column values map 1:1 from the captured response; a null/empty column name omits that column. +public class ResponseTempTableOptions +{ + /// Temp table name when a test file has exactly one HTTP block. + public string Name { get; set; } = "_response"; + + /// Temp table name pattern when a file has 2+ HTTP blocks. {n} = the 1-based block ordinal. + public string MultiNamePattern { get; set; } = "_response_{n}"; + + public ResponseColumnOptions Columns { get; set; } = new(); +} + +public class ResponseColumnOptions +{ + public string? Status { get; set; } = "status"; + public string? Body { get; set; } = "body"; + public string? ContentType { get; set; } = "content_type"; + public string? Headers { get; set; } = "headers"; + public string? IsSuccess { get; set; } = "is_success"; +} + +/// One Setup/Teardown step — exactly one of Sql / SqlFile / Command. +public class TestSetupStep +{ + /// Inline SQL executed as a single batch. + public string? Sql { get; set; } + + /// Path (cwd-relative) to a .sql file executed as a single batch. + public string? SqlFile { get; set; } + + /// Shell command (run via the OS shell). Non-zero exit aborts a Setup run. + public string? Command { get; set; } + + /// Working directory for (cwd-relative); null => current directory. + public string? WorkingDirectory { get; set; } + + public bool IsCommand => !string.IsNullOrWhiteSpace(Command); +} diff --git a/NpgsqlRestClient/Testing/TestStep.cs b/NpgsqlRestClient/Testing/TestStep.cs new file mode 100644 index 00000000..2ffeb0ae --- /dev/null +++ b/NpgsqlRestClient/Testing/TestStep.cs @@ -0,0 +1,75 @@ +namespace NpgsqlRestClient.Testing; + +/// Kind of a parsed test step. +public enum TestStepKind +{ + /// A SQL statement (or DO block) executed on the test connection. + Sql, + /// An embedded HTTP request (a /* ... */ block whose first line is a request line). + Http, +} + +/// One ordered step in a parsed .test.sql file. +public abstract class TestStep +{ + public TestStepKind Kind { get; } + /// 1-based line in the source file where this step starts (for error reporting). + public int LineNumber { get; } + + protected TestStep(TestStepKind kind, int lineNumber) + { + Kind = kind; + LineNumber = lineNumber; + } +} + +/// A SQL statement step — executed via ExecuteReader; a boolean first column is an assertion. +public sealed class SqlStep : TestStep +{ + public string Text { get; } + public bool IsDoBlock { get; } + + public SqlStep(string text, bool isDoBlock, int lineNumber) : base(TestStepKind.Sql, lineNumber) + { + Text = text; + IsDoBlock = isDoBlock; + } +} + +/// An embedded single-request HTTP step parsed from a block comment. +public sealed class HttpStep : TestStep +{ + /// HTTP method (upper-cased): GET, PUT, POST, DELETE. + public string Method { get; } + /// Request target verbatim, including any query string. Always starts with '/'. + public string Path { get; } + /// Request headers in declared order (duplicates allowed). + public IReadOnlyList<(string Name, string Value)> Headers { get; } + /// Claims for the acting principal (from # @claim name=value), in declared order. + public IReadOnlyList<(string Name, string Value)> Claims { get; } + /// Request body (verbatim), or null when none. + public string? Body { get; } + /// Expected status from # @expect-status NNN, or null. + public int? ExpectStatus { get; } + /// Response temp-table name override from # @response name, or null (use the configured default). + public string? ResponseTable { get; } + + public HttpStep( + string method, + string path, + IReadOnlyList<(string Name, string Value)> headers, + IReadOnlyList<(string Name, string Value)> claims, + string? body, + int? expectStatus, + string? responseTable, + int lineNumber) : base(TestStepKind.Http, lineNumber) + { + Method = method; + Path = path; + Headers = headers; + Claims = claims; + Body = body; + ExpectStatus = expectStatus; + ResponseTable = responseTable; + } +} diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 2146a723..db81a5c2 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -881,11 +881,14 @@ // // See https://github.com/serilog/serilog/wiki/Configuration-Basics#minimum-level // Verbose, Debug, Information, Warning, Error, Fatal. + // Set a level to "Off" (aliases "None"/"Silent") to mute that logger entirely; use null (or omit it) to fall back to its built-in default. // Note: NpgsqlRest logger applies to main application logger, which will, by default have the name defined in the ApplicationName setting. + // NpgsqlRestTest is the SQL test runner (--test) channel (see TestRunner:LoggerName): discovery/parsing at Debug, each query and HTTP call at Verbose, notices by severity. // "MinimalLevels": { "NpgsqlRest": "Information", "NpgsqlRestClient": "Information", + "NpgsqlRestTest": "Information", "System": "Warning", "Microsoft": "Warning" }, @@ -1422,6 +1425,82 @@ "ActivityPath": "/stats/activity" }, + // + // SQL test runner. Invoked with the `--test` command-line flag (this section is otherwise inert). + // Discovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints + // in-process from an embedded `/* GET /path */` block (response captured into a temp table), and + // asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks. Exit codes: + // 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found. + // + "TestRunner": { + // + // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. + // Co-located convention: app.sql (endpoint) next to app.test.sql (test). + // + "FilePattern": "", + // + // Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection. + // + "MaxParallelism": 0, + // + // Stop scheduling new tests after the first failure/error (in-flight tests still finish). + // + "FailFast": false, + // + // Per-test timeout. Accepts "30s", "5m", "1h", a plain number of seconds, or "hh:mm:ss". 0 disables. + // + "PerTestTimeout": "30s", + // + // Optional path to also write a JUnit XML report (console output is always printed). + // + "JUnitOutput": null, + // + // Skip Teardown so a failed run's state can be inspected. + // + "Keep": false, + // + // Show captured `raise notice` output for all tests, not just failed/errored ones. + // + "Verbose": false, + // + // Treat "no tests discovered" as success (exit 0) instead of exit 4. + // + "AllowEmpty": false, + // + // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. + // Discovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. + // + "LoggerName": "NpgsqlRestTest", + // + // Per-HTTP-block temp table that captures the response. Each block gets its own fresh temp table + // (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. + // A file with ONE HTTP block uses "Name"; a file with 2+ blocks uses "MultiNamePattern" where {n} is + // the 1-based block ordinal (_response_1, _response_2, ...). Per-block override: `# @response `. + // A null/empty column name omits that column. + // + "ResponseTempTable": { + "Name": "_response", + "MultiNamePattern": "_response_{n}", + "Columns": { + "Status": "status", + "Body": "body", + "ContentType": "content_type", + "Headers": "headers", + "IsSuccess": "is_success" + } + }, + // + // Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra, e.g. docker), + // then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group. Each entry: + // { "Command": "...", "WorkingDirectory": "..." } | { "SqlFile": "..." } | { "Sql": "..." } + // + "Setup": [], + // + // Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command. `Keep` skips it. + // + "Teardown": [] + }, + // // Command retry strategies and options for client and middleware commands. // @@ -2908,6 +2987,12 @@ // "FilePattern": "", // + // Glob (same semantics as FilePattern) for files to EXCLUDE from endpoint discovery. + // Default "*.test.sql" so co-located SQL test files (run by the test runner, see "TestRunner") + // are never exposed as endpoints. Empty string disables the exclusion. + // + "SkipPattern": "*.test.sql", + // // How comment annotations are processed for SQL file endpoints. // Possible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag. // OnlyAnnotated (default; OnlyWithHttpTag is a back-compat alias) requires an explicit diff --git a/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs b/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs new file mode 100644 index 00000000..09ca1252 --- /dev/null +++ b/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs @@ -0,0 +1,129 @@ +using NpgsqlRestClient.Testing; + +namespace NpgsqlRestTests.TestRunnerTests.ParserTests; + +public class HttpFileRequestParserTests +{ + private static HttpStep Parse(string body) + { + var step = HttpFileRequestParser.TryParse(body, 1); + step.Should().NotBeNull("the block should be recognized as an HTTP request"); + return step!; + } + + [Fact] + public void Get_With_Path_Is_Recognized() + { + var s = Parse("GET /get-users"); + s.Method.Should().Be("GET"); + s.Path.Should().Be("/get-users"); + s.Body.Should().BeNull(); + } + + [Fact] + public void Query_String_Is_Kept_In_Path() + { + Parse("GET /users?active=true&role=admin").Path.Should().Be("/users?active=true&role=admin"); + } + + [Fact] + public void Leading_Http_Keyword_Is_Tolerated() + { + var s = Parse("HTTP POST /things"); + s.Method.Should().Be("POST"); + s.Path.Should().Be("/things"); + } + + [Fact] + public void Method_Is_Case_Insensitive_And_Uppercased() + { + Parse("post /things").Method.Should().Be("POST"); + } + + [Fact] + public void Trailing_Http_Version_Is_Ignored() + { + var s = Parse("GET /users HTTP/1.1"); + s.Method.Should().Be("GET"); + s.Path.Should().Be("/users"); + } + + [Theory] + [InlineData("this is just a note")] + [InlineData("/api/foo")] // no method + [InlineData("GET the data")] // path doesn't start with '/' + [InlineData("PATCH /things")] // method not in GET|PUT|POST|DELETE + [InlineData("HEAD /things")] + [InlineData("GET /data from the cache")] // trailing junk that isn't a version + [InlineData("")] + public void Non_Request_Blocks_Are_Not_Recognized(string body) + { + HttpFileRequestParser.TryParse(body, 1).Should().BeNull(); + } + + [Fact] + public void Headers_Are_Parsed_Until_Blank_Line() + { + var s = Parse("POST /things\nContent-Type: application/json\nX-Trace: abc\n\n{\"a\":1}"); + s.Headers.Should().HaveCount(2); + s.Headers.Should().Contain(("Content-Type", "application/json")); + s.Headers.Should().Contain(("X-Trace", "abc")); + s.Body.Should().Be("{\"a\":1}"); + } + + [Fact] + public void Claim_Directive_Adds_Claim() + { + var s = Parse("POST /things\n# @claim user_id=123\n\n{}"); + s.Claims.Should().ContainSingle() + .Which.Should().Be(("user_id", "123")); + } + + [Fact] + public void Repeated_Claim_Type_Yields_Multiple_Values() + { + var s = Parse("POST /things\n# @claim roles=admin\n# @claim roles=editor\n\n{}"); + s.Claims.Should().HaveCount(2); + s.Claims.Should().Contain(("roles", "admin")); + s.Claims.Should().Contain(("roles", "editor")); + } + + [Fact] + public void Expect_Status_And_Response_Directives_Are_Parsed() + { + var s = Parse("GET /x\n# @expect-status 404\n# @response listing"); + s.ExpectStatus.Should().Be(404); + s.ResponseTable.Should().Be("listing"); + } + + [Fact] + public void Double_Slash_Comment_And_Directive_Variants_Work() + { + var s = Parse("// a note\nGET /x\n// @claim user_id=7"); + s.Method.Should().Be("GET"); + s.Claims.Should().ContainSingle().Which.Value.Should().Be("7"); + } + + [Fact] + public void Leading_Comment_Lines_Before_Request_Are_Skipped() + { + var s = Parse("# create the thing\nPOST /things\n\n{\"name\":\"x\"}"); + s.Method.Should().Be("POST"); + s.Body.Should().Be("{\"name\":\"x\"}"); + } + + [Fact] + public void Body_With_Colons_Is_Not_Parsed_As_Headers() + { + var s = Parse("POST /things\n\n{\"url\": \"http://x\", \"k\": \"v\"}"); + s.Headers.Should().BeEmpty(); + s.Body.Should().Be("{\"url\": \"http://x\", \"k\": \"v\"}"); + } + + [Fact] + public void Multiline_Body_Is_Preserved() + { + var s = Parse("POST /things\n\n{\n \"a\": 1\n}"); + s.Body.Should().Be("{\n \"a\": 1\n}"); + } +} diff --git a/NpgsqlRestTests/TestRunnerTests/ParserTests/SqlTestFileParserTests.cs b/NpgsqlRestTests/TestRunnerTests/ParserTests/SqlTestFileParserTests.cs new file mode 100644 index 00000000..d5d5cb44 --- /dev/null +++ b/NpgsqlRestTests/TestRunnerTests/ParserTests/SqlTestFileParserTests.cs @@ -0,0 +1,131 @@ +using NpgsqlRestClient.Testing; + +namespace NpgsqlRestTests.TestRunnerTests.ParserTests; + +public class SqlTestFileParserTests +{ + [Fact] + public void Single_Statement_Yields_One_Sql_Step() + { + var steps = SqlTestFileParser.Parse("select 1;"); + steps.Should().ContainSingle(); + var sql = steps[0].Should().BeOfType().Subject; + sql.Text.Should().Be("select 1"); + sql.IsDoBlock.Should().BeFalse(); + } + + [Fact] + public void Two_Statements_Split_On_Semicolon() + { + var steps = SqlTestFileParser.Parse("select 1;\nselect 2;"); + steps.Should().HaveCount(2); + steps.Should().AllBeOfType(); + ((SqlStep)steps[0]).Text.Should().Be("select 1"); + ((SqlStep)steps[1]).Text.Should().Be("select 2"); + } + + [Fact] + public void Trailing_Statement_Without_Semicolon_Is_Flushed() + { + var steps = SqlTestFileParser.Parse("select 1;\nselect 2"); + steps.Should().HaveCount(2); + ((SqlStep)steps[1]).Text.Should().Be("select 2"); + } + + [Fact] + public void Do_Block_Is_A_Single_Step_With_Inner_Semicolons_Kept() + { + var sqlText = "do $$\nbegin\n insert into t values (1);\n assert true, 'x';\nend;\n$$;"; + var steps = SqlTestFileParser.Parse(sqlText); + steps.Should().ContainSingle(); + var sql = (SqlStep)steps[0]; + sql.IsDoBlock.Should().BeTrue(); + sql.Text.Should().Contain("insert into t values (1)"); + sql.Text.Should().Contain("assert true"); + } + + [Fact] + public void Semicolon_Inside_Single_Quote_Does_Not_Split() + { + var steps = SqlTestFileParser.Parse("select 'a;b' as v;"); + steps.Should().ContainSingle(); + ((SqlStep)steps[0]).Text.Should().Be("select 'a;b' as v"); + } + + [Fact] + public void Escaped_Quote_Inside_String_Is_Handled() + { + var steps = SqlTestFileParser.Parse("select 'O''Reilly; co' as v;"); + steps.Should().ContainSingle(); + ((SqlStep)steps[0]).Text.Should().Be("select 'O''Reilly; co' as v"); + } + + [Fact] + public void Tagged_Dollar_Quote_With_Inner_Semicolon() + { + var steps = SqlTestFileParser.Parse("select $tag$ a; b $tag$ as v;"); + steps.Should().ContainSingle(); + ((SqlStep)steps[0]).Text.Should().Contain("$tag$ a; b $tag$"); + } + + [Fact] + public void Line_Comments_Are_Ignored() + { + var steps = SqlTestFileParser.Parse("-- a note\nselect 1; -- trailing\nselect 2;"); + steps.Should().HaveCount(2); + } + + [Fact] + public void NonHttp_Block_Comment_Is_Ignored() + { + var steps = SqlTestFileParser.Parse("/* just a note */\nselect 1;"); + steps.Should().ContainSingle().Which.Should().BeOfType(); + } + + [Fact] + public void Http_Block_Between_Statements_Preserves_Order() + { + var sqlText = + "insert into t values (1);\n" + + "/*\nGET /x\n*/\n" + + "do $$ begin assert (select status from _response) = 200; end $$;"; + var steps = SqlTestFileParser.Parse(sqlText); + steps.Should().HaveCount(3); + steps[0].Should().BeOfType(); + steps[1].Should().BeOfType(); + steps[2].Should().BeOfType(); + var http = (HttpStep)steps[1]; + http.Method.Should().Be("GET"); + http.Path.Should().Be("/x"); + } + + [Fact] + public void Http_Block_First_Yields_Http_Step_First() + { + var steps = SqlTestFileParser.Parse("/*\nPOST /things\n\n{\"a\":1}\n*/\nselect 1;"); + steps.Should().HaveCount(2); + steps[0].Should().BeOfType(); + ((HttpStep)steps[0]).Body.Should().Be("{\"a\":1}"); + steps[1].Should().BeOfType(); + } + + [Fact] + public void Line_Numbers_Are_Tracked() + { + var sqlText = "select 1;\n\n/*\nGET /x\n*/\nselect 2;"; + var steps = SqlTestFileParser.Parse(sqlText); + steps.Should().HaveCount(3); + steps[0].LineNumber.Should().Be(1); // select 1 + steps[1].LineNumber.Should().Be(3); // /* opens on line 3 + steps[2].LineNumber.Should().Be(6); // select 2 + } + + [Fact] + public void Block_Comment_In_Middle_Of_Statement_Is_Stripped() + { + var steps = SqlTestFileParser.Parse("select /* note */ 1;"); + steps.Should().ContainSingle(); + ((SqlStep)steps[0]).Text.Should().StartWith("select"); + ((SqlStep)steps[0]).Text.Should().EndWith("1"); + } +} diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md new file mode 100644 index 00000000..f5ef3c21 --- /dev/null +++ b/changelog/v3.19.0.md @@ -0,0 +1,46 @@ +# Changelog v3.19.0 + +## Version [3.19.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.19.0) (2026-06-30) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.18.2...3.19.0) + +Minor release adding two small, independent configuration options: a **skip glob for the SQL file source** (exclude files from endpoint discovery) and the ability to **mute an individual logger** via `Log:MinimalLevels`. + +## What changed + +### 1. `SqlFileSource.SkipPattern` — exclude files from endpoint discovery + +New option **`SkipPattern`** on the SQL file source (config key `NpgsqlRest:SqlFileSource:SkipPattern`, default **`"*.test.sql"`**). Files whose full path matches this glob are excluded from endpoint discovery: a `.sql` file becomes an endpoint only when it matches **`FilePattern`** *and* does **not** match **`SkipPattern`**. + +This lets you keep auxiliary `.sql` files that live alongside your endpoint files — for example co-located SQL files following the `*.test.sql` naming convention — without them being turned into HTTP endpoints. The pattern uses the same glob engine and semantics as `FilePattern` (`*.ext` matches by suffix). Set it to an empty string (`""`) to disable the exclusion. + +> **Behavior change:** the default is `"*.test.sql"`, so files matching that suffix are **no longer exposed** as endpoints out of the box. If you previously relied on serving `*.test.sql` files, set `SkipPattern` to `""` to restore the old behavior. + +### 2. Mute an individual logger with `"Off"` in `Log:MinimalLevels` + +Each entry under **`Log:MinimalLevels`** now accepts **`"Off"`** (aliases **`"None"`** and **`"Silent"`**, case-insensitive) to **fully silence** that logger. Previously the only accepted values were the Serilog levels `Verbose…Fatal`, and there was no way to turn a logger off completely. + +- `"Off"` / `"None"` / `"Silent"` → the logger emits nothing (implemented as a minimum level above `Fatal`, since Serilog's `LogEventLevel` has no native "off"). +- `null`, an omitted key, or an unrecognized value → unchanged: the logger keeps its built-in default level. + +Each named logger is controlled independently, so you can, for example, mute the `System`/`Microsoft` framework logs entirely while keeping the application loggers at `Information`: + +```json +"Log": { + "MinimalLevels": { + "NpgsqlRest": "Information", + "NpgsqlRestClient": "Information", + "System": "Off", + "Microsoft": "Off" + } +} +``` + +## Notes + +- Both options are wired through the client configuration: `appsettings.json`, the JSON-schema descriptions, and the `--config` template (which continues to match `appsettings.json` byte-for-byte). +- Fully additive apart from the `SkipPattern` default noted above; no API changes. + +## Tests + +Full test suite green (2334). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs index 493f53b0..7edf6c19 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs @@ -24,7 +24,7 @@ public class SqlFileSource(SqlFileSourceOptions options) : IEndpointSource yield break; } - var files = FindMatchingFiles(options.FilePattern).ToArray(); + var files = FindMatchingFiles(options.FilePattern, options.SkipPattern).ToArray(); if (files.Length == 0) { NpgsqlRestOptions.Logger?.LogWarning("SqlFileSource: No SQL files found matching pattern \"{FilePattern}\"", options.FilePattern); @@ -619,7 +619,7 @@ and a.attnum > 0 and not a.attisdropped /// Find files matching the glob pattern. Splits the pattern into a base directory /// and a file pattern, then lazily enumerates matching files. /// - internal static IEnumerable FindMatchingFiles(string filePattern) + internal static IEnumerable FindMatchingFiles(string filePattern, string? skipPattern = null) { // Find the base directory (everything before the first wildcard) int firstWildcard = filePattern.IndexOfAny(['*', '?']); @@ -658,10 +658,12 @@ internal static IEnumerable FindMatchingFiles(string filePattern) bool isRecursive = filePattern.Contains("**"); var searchOption = isRecursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + bool hasSkip = !string.IsNullOrEmpty(skipPattern); foreach (var file in Directory.EnumerateFiles(baseDir, "*", searchOption)) { var normalizedFile = file.Replace('\\', '/'); - if (Parser.IsPatternMatch(normalizedFile, pattern)) + if (Parser.IsPatternMatch(normalizedFile, pattern) + && !(hasSkip && Parser.IsPatternMatch(normalizedFile, skipPattern!))) { yield return file; } diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSourceOptions.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSourceOptions.cs index 4d093e03..094eac65 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSourceOptions.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSourceOptions.cs @@ -34,6 +34,13 @@ public class SqlFileSourceOptions /// public string FilePattern { get; set; } = ""; + /// + /// Glob (same semantics as ) for files to EXCLUDE from endpoint discovery. + /// Default "*.test.sql" so co-located SQL test files (run by the test runner) are never exposed as + /// endpoints. Empty disables the exclusion. Matched against the full path; "*.ext" matches by suffix. + /// + public string SkipPattern { get; set; } = "*.test.sql"; + /// /// How comment annotations are processed for this source. /// Default is OnlyWithHttpTag — SQL files must contain an explicit HTTP annotation From 0a20ec9b12bb9982632374b7e78a7442d55cf267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 11:22:21 +0200 Subject: [PATCH 02/14] feat(test-runner): test database support, per-assertion hardening, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test database / separate test connection: - TestRunner.ConnectionName: run tests (endpoint Describe + execution) against a named ConnectionStrings entry instead of the app's main connection; not opened/validated at startup, so a Setup step can create the database first. - Per-step ConnectionName on Setup/Teardown Sql/SqlFile steps (e.g. an Admin maintenance connection running `create database`); runner issues no DDL of its own. Steps now run in the exact order written (dropped Command-first grouping). BuildTestRunnerConnectionStrings resolves entries unvalidated. - {rnd1}..{rnd10} config placeholders: random lowercase tokens (length = digit), generated once per run, substituted everywhere {ENV} works — one stable name across connection string, create, and drop. Hardening round (full-feature review): - `# @expect-status` removed — assert on the response temp table's status column instead; HTTP blocks are pure act steps. - Code generation (HTTP files, TS client, OpenAPI) skipped in --test mode — a test run never rewrites generated artifacts. - login/logout endpoints rejected in test mode with a clear error (inject the principal with `# @claim` instead). - Request matching no endpoint logs a warning (path typo / missing /api prefix) — the 404 remains assertable. - Per-step exception attribution: errors thrown by HTTP steps (e.g. 42P07 duplicate `# @response` table) report with the block's file:line. Rename: TestRunner.Verbose -> DetailedReport (detailed console report: passed ✓ lines, full failing SQL, notices for passing tests) — removes the collision with the Serilog "Verbose" level in Log:MinimalLevels. changelog/v3.19.0.md rewritten: SQL test runner documented as the headline feature with a full user manual (execution model, file anatomy, HTTP-block syntax, response temp tables, Setup/Teardown, test-database workflow, reporting, logging, config reference), plus SqlFileSource.SkipPattern and MinimalLevels "Off". Config synced across appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs. Full suite green (2334); examples 19 + 20 verified end-to-end (fresh {rnd}-named test DB created, migrated, tested, dropped). --- NpgsqlRestClient/App.cs | 13 +- NpgsqlRestClient/Builder.cs | 74 +++++- NpgsqlRestClient/Config.cs | 21 ++ NpgsqlRestClient/ConfigDefaults.cs | 3 +- NpgsqlRestClient/ConfigSchemaGenerator.cs | 7 +- NpgsqlRestClient/ConfigTemplate.cs | 24 +- NpgsqlRestClient/Program.cs | 4 +- .../Testing/HttpFileRequestParser.cs | 12 +- NpgsqlRestClient/Testing/TestRunner.cs | 150 +++++++---- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 26 +- NpgsqlRestClient/Testing/TestStep.cs | 4 - NpgsqlRestClient/appsettings.json | 24 +- .../ParserTests/HttpFileRequestParserTests.cs | 5 +- changelog/v3.19.0.md | 251 +++++++++++++++++- 14 files changed, 499 insertions(+), 119 deletions(-) diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index dd55f2b6..ba97954b 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -463,10 +463,17 @@ public string CreateUrl(Routine routine, NpgsqlRestOptions options) => public List CreateCodeGenHandlers(string connectionString, string[] args) { List handlers = new(3); - if (args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase) - || string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase))) + if (args.Any(a => string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase))) + { + // Test mode: only capture endpoints for the runner (kind pre-checks: SSE/upload/login/outbound). + // Code generation (HTTP files, TS client, OpenAPI) is deliberately skipped so a test run never + // rewrites generated artifacts as a side effect. + handlers.Add(new EndpointCapture()); + _builder.TestLogger?.LogDebug("code generation handlers skipped in test mode"); + return handlers; + } + if (args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase))) { - // --test also captures endpoints so the runner can pre-check kinds (SSE/upload/outbound). handlers.Add(new EndpointCapture()); } var httpFilecfg = _config.NpgsqlRestCfg.GetSection("HttpFileOptions"); diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index d6ce259a..882ba793 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -1724,10 +1724,11 @@ public bool BuildForwardedHeaders() } private (string?, ConnectionRetryOptions) BuildConnection( - string? connectionName, - string connectionString, + string? connectionName, + string connectionString, bool isMain, - bool skipRetryOpts) + bool skipRetryOpts, + bool skipValidation = false) { if (_config.EnvDict is not null) { @@ -1807,7 +1808,7 @@ public bool BuildForwardedHeaders() string.Join(",", retryOptions.Strategy.ErrorCodes)); } } - if (_config.GetConfigBool("TestConnectionStrings", _config.ConnectionSettingsCfg, true) is true) + if (skipValidation is false && _config.GetConfigBool("TestConnectionStrings", _config.ConnectionSettingsCfg, true) is true) { using var conn = new NpgsqlConnection(connectionString); try @@ -1823,10 +1824,37 @@ public bool BuildForwardedHeaders() return (connectionString, retryOptions); } - public (string?, ConnectionRetryOptions) BuildConnectionString() + public (string?, ConnectionRetryOptions) BuildConnectionString(bool testMode = false) { string? connectionString; - string? connectionName = _config.GetConfigStr("ConnectionName", _config.NpgsqlRestCfg); + string? connectionName = null; + bool skipValidation = false; + + // In test mode a dedicated TestRunner.ConnectionName (when set) becomes the app's main connection, + // so endpoint Describe + execution run against the TEST database. That database may not exist until + // TestRunner.Setup creates it (which runs after this), so it is NOT opened/validated here — it is + // first touched by Setup / Describe, by which point Setup has created it. + if (testMode) + { + connectionName = _config.GetConfigStr("ConnectionName", _config.Cfg.GetSection("TestRunner")); + if (connectionName is not null) + { + connectionString = _config.Cfg.GetConnectionString(connectionName); + skipValidation = true; + if (connectionString is null) + { + throw new InvalidOperationException( + $"TestRunner.ConnectionName is '{connectionName}', but there is no matching entry under 'ConnectionStrings'."); + } + var (testResult, testRetryOpts) = BuildConnection(connectionName, connectionString, isMain: true, skipRetryOpts: false, skipValidation: true); + AppContext.SetSwitch("Npgsql.EnableSqlRewriting", false); + ConnectionString = testResult; + ConnectionName = connectionName; + return (testResult, testRetryOpts); + } + } + + connectionName = _config.GetConfigStr("ConnectionName", _config.NpgsqlRestCfg); if (connectionName is not null) { connectionString = _config.Cfg.GetConnectionString(connectionName); @@ -1846,7 +1874,7 @@ public bool BuildForwardedHeaders() "or set the PGHOST, PGDATABASE, PGUSER, and PGPASSWORD environment variables with 'Config.AddEnvironmentVariables: true'."); } - var (result, retryOpts) = BuildConnection(connectionName, connectionString!, isMain: true, skipRetryOpts: false); + var (result, retryOpts) = BuildConnection(connectionName, connectionString!, isMain: true, skipRetryOpts: false, skipValidation: skipValidation); // disable SQL rewriting to ensure that NpgsqlRest works with this option OFF. AppContext.SetSwitch("Npgsql.EnableSqlRewriting", false); @@ -1879,6 +1907,34 @@ public IDictionary BuildConnectionStringDict() return result.ToFrozenDictionary(); } + /// + /// Resolves every ConnectionStrings entry (env/{rnd} placeholders substituted, pooling disabled) + /// into a name→connection-string map for the SQL test runner's Setup/Teardown ConnectionName + /// steps. Unlike this does NOT open/validate the connections — + /// a test database referenced here may not exist until a Setup step creates it. + /// + public Dictionary BuildTestRunnerConnectionStrings() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var section in _config.Cfg.GetSection("ConnectionStrings").GetChildren()) + { + if (string.IsNullOrEmpty(section?.Key) || string.IsNullOrEmpty(section.Value)) + { + continue; + } + var cs = section.Value; + if (_config.EnvDict is not null) + { + cs = Formatter.FormatString(cs.AsSpan(), _config.EnvDict).ToString(); + } + // Non-pooled: maintenance/test sessions are short-lived and must not linger (a pooled session + // would block DROP DATABASE in a Teardown step). + var b = new NpgsqlConnectionStringBuilder(cs) { Pooling = false, Enlist = false }; + result[section.Key] = b.ConnectionString; + } + return result; + } + private string? _instanceId = null; public string InstanceId @@ -3012,12 +3068,13 @@ public TestRunnerOptions BuildTestRunnerOptions() if (section.Exists() is false) return opt; opt.FilePattern = _config.GetConfigStr("FilePattern", section) ?? ""; + opt.ConnectionName = _config.GetConfigStr("ConnectionName", section); opt.MaxParallelism = _config.GetConfigInt("MaxParallelism", section) ?? 0; opt.FailFast = _config.GetConfigBool("FailFast", section, false); opt.PerTestTimeout = ParseTestTimeout(_config.GetConfigStr("PerTestTimeout", section), TimeSpan.FromSeconds(30)); opt.JUnitOutput = _config.GetConfigStr("JUnitOutput", section); opt.Keep = _config.GetConfigBool("Keep", section, false); - opt.Verbose = _config.GetConfigBool("Verbose", section, false); + opt.DetailedReport = _config.GetConfigBool("DetailedReport", section, false); opt.AllowEmpty = _config.GetConfigBool("AllowEmpty", section, false); var rt = section.GetSection("ResponseTempTable"); @@ -3061,6 +3118,7 @@ List ReadTestSteps(IConfigurationSection stepsSection) SqlFile = _config.GetConfigStr("SqlFile", child), Command = _config.GetConfigStr("Command", child), WorkingDirectory = _config.GetConfigStr("WorkingDirectory", child), + ConnectionName = _config.GetConfigStr("ConnectionName", child), }; if (step.Sql is not null || step.SqlFile is not null || step.Command is not null) { diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index 55b257e0..2678f8cf 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -105,6 +105,14 @@ public void Build(string[] args, string[] skip) { EnvDict.Add(key.ToString()!, envVars[key.ToString()!]?.ToString()!); } + // {rnd1}..{rnd10}: random lowercase tokens (length = the trailing digit) generated once per + // process and stable across the whole config — usable wherever {ENV} placeholders are + // (connection strings, TestRunner Setup/Teardown). E.g. a test DB named per run: + // "ConnectionStrings:Test": "...;Database=app_test_{rnd6};..." + "create database app_test_{rnd6}". + for (int n = 1; n <= 10; n++) + { + EnvDict[string.Concat("rnd", n.ToString(System.Globalization.CultureInfo.InvariantCulture))] = RandomToken(n); + } } UseJsonApplicationName = GetConfigBool("UseJsonApplicationName", ConnectionSettingsCfg); @@ -151,6 +159,19 @@ public bool GetConfigBool(string key, IConfiguration? subsection = null, bool de }; } + // A random lowercase-letter token of the given length, used for the {rnd1}..{rnd10} config placeholders. + private static string RandomToken(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyz"; + return string.Create(length, chars, static (span, c) => + { + for (int i = 0; i < span.Length; i++) + { + span[i] = c[Random.Shared.Next(c.Length)]; + } + }); + } + // True when the value still contains a {TOKEN} placeholder, i.e. an environment variable that was // not resolved because it is not set. Typed config values (bool/int) never legitimately contain // braces, so an unresolved token is unambiguously a missing-env-var, not a real value. diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 93e5ab26..b47ec6da 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1101,12 +1101,13 @@ private static JsonObject GetTestRunnerDefaults() return new JsonObject { ["FilePattern"] = "", + ["ConnectionName"] = "", ["MaxParallelism"] = 0, ["FailFast"] = false, ["PerTestTimeout"] = "30s", ["JUnitOutput"] = null, ["Keep"] = false, - ["Verbose"] = false, + ["DetailedReport"] = false, ["AllowEmpty"] = false, ["LoggerName"] = "NpgsqlRestTest", ["ResponseTempTable"] = new JsonObject diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index e84405cb..959d8a45 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -248,19 +248,20 @@ public static partial class ConfigSchemaGenerator ["Stats:ActivityPath"] = "Path for current database activity.\nReturns data from pg_stat_activity showing active sessions, queries, and wait events.\nSecurity Consideration: Shows currently running queries which may contain sensitive data.", ["TestRunner"] = "SQL test runner. Invoked with the `--test` command-line flag (otherwise inert).\nDiscovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints in-process from an embedded `/* GET /path */` block (response captured into a temp table), and asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks.\nExit codes: 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found.", ["TestRunner:FilePattern"] = "Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables discovery.\nCo-located convention: app.sql (endpoint) next to app.test.sql (test).", + ["TestRunner:ConnectionName"] = "Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection.\nIn test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can point at a dedicated test database that a Setup step creates first (it need not exist at startup). Empty = use the main connection.\nTip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}).", ["TestRunner:MaxParallelism"] = "Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection.", ["TestRunner:FailFast"] = "Stop scheduling new tests after the first failure/error (in-flight tests still finish).", ["TestRunner:PerTestTimeout"] = "Per-test timeout. Accepts \"30s\", \"5m\", \"1h\", a plain number of seconds, or \"hh:mm:ss\". 0 disables.", ["TestRunner:JUnitOutput"] = "Optional path to also write a JUnit XML report (console output is always printed).", ["TestRunner:Keep"] = "Skip Teardown so a failed run's state can be inspected.", - ["TestRunner:Verbose"] = "Show captured `raise notice` output for all tests, not just failed/errored ones.", + ["TestRunner:DetailedReport"] = "Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show captured `raise notice` output for passing tests too.\nThis shapes the report only — for diagnostic logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName).", ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", ["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.", - ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra), then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group.\nEach entry: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } | { \"SqlFile\": \"...\" } | { \"Sql\": \"...\" }.", - ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command (e.g. docker down). The `Keep` option skips it.", + ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is one of: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).", + ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), in the EXACT order written. Same step shapes as Setup (e.g. a `drop database` on an admin connection). The `Keep` option skips it.", ["CommandRetryOptions"] = "Command retry strategies and options for client and middleware commands.", ["CommandRetryOptions:Strategies:default:RetrySequenceSeconds"] = "Retry sequence in seconds. Accepts decimal numbers (0.25 is quarter of a second). The length of the array determines the maximum number of retries.", ["CommandRetryOptions:Strategies:default:ErrorCodes"] = "Error codes that will trigger a retry when executing a command. See https://www.postgresql.org/docs/current/errcodes-appendix.html", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index f992f6f2..fe68090e 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1448,6 +1448,14 @@ public static partial class ConfigSchemaGenerator // "FilePattern": "", // + // Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection. In + // test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can + // point at a dedicated test database that a Setup step creates first (it need not exist at startup). + // Empty = use the main connection. Tip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), + // stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). + // + "ConnectionName": "", + // // Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection. // "MaxParallelism": 0, @@ -1468,9 +1476,11 @@ public static partial class ConfigSchemaGenerator // "Keep": false, // - // Show captured `raise notice` output for all tests, not just failed/errored ones. + // Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show + // captured `raise notice` output for passing tests too. This shapes the report only — for diagnostic + // logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName). // - "Verbose": false, + "DetailedReport": false, // // Treat "no tests discovered" as success (exit 0) instead of exit 4. // @@ -1499,13 +1509,15 @@ public static partial class ConfigSchemaGenerator } }, // - // Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra, e.g. docker), - // then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group. Each entry: - // { "Command": "...", "WorkingDirectory": "..." } | { "SqlFile": "..." } | { "Sql": "..." } + // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is one of: + // { "Command": "...", "WorkingDirectory": "..." } — runs via the OS shell + // { "Sql": "..." } | { "SqlFile": "..." } — runs on the test connection; set "ConnectionName" + // to run on another ConnectionStrings entry (e.g. + // an admin connection that runs `create database`). // "Setup": [], // - // Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command. `Keep` skips it. + // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same step shapes as Setup. // "Teardown": [] }, diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index a10031b6..5444b9b7 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -301,7 +301,7 @@ var errorHandlingOptions = builder.BuildErrorHandlingOptions(); var (rateLimiterDefaultPolicy, rateLimiterEnabled) = builder.BuildRateLimiter(); -var (connectionString, retryOpts) = builder.BuildConnectionString(); +var (connectionString, retryOpts) = builder.BuildConnectionString(testMode); if (connectionString is null) { return; @@ -500,7 +500,7 @@ var logTestNotices = options.LogConnectionNoticeEvents; options.LogConnectionNoticeEvents = false; - testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices); + testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices, builder.BuildTestRunnerConnectionStrings()); if (await testRunner.SetupAsync() is false) { Environment.ExitCode = NpgsqlRestClient.Testing.TestRunner.ExitConfig; diff --git a/NpgsqlRestClient/Testing/HttpFileRequestParser.cs b/NpgsqlRestClient/Testing/HttpFileRequestParser.cs index e5939a60..edc05564 100644 --- a/NpgsqlRestClient/Testing/HttpFileRequestParser.cs +++ b/NpgsqlRestClient/Testing/HttpFileRequestParser.cs @@ -2,7 +2,7 @@ namespace NpgsqlRestClient.Testing; /// /// Parses a single HTTP request out of a block-comment body, using a single-request subset of the -/// Microsoft .http syntax plus the test directives # @claim, # @expect-status, # @response. +/// Microsoft .http syntax plus the test directives # @claim and # @response. /// Returns null when the block is not an HTTP request (ordinary SQL comment). /// public static class HttpFileRequestParser @@ -36,7 +36,6 @@ public static class HttpFileRequestParser var headers = new List<(string, string)>(); var claims = new List<(string, string)>(); - int? expectStatus = null; string? responseTable = null; int bodyStart = -1; @@ -57,7 +56,7 @@ public static class HttpFileRequestParser { if (directive.StartsWith('@')) { - ParseDirective(directive[1..].Trim(), claims, ref expectStatus, ref responseTable); + ParseDirective(directive[1..].Trim(), claims, ref responseTable); } // plain comment line → ignore continue; @@ -79,7 +78,7 @@ public static class HttpFileRequestParser if (bodyText.Length > 0) body = bodyText; } - return new HttpStep(method, path, headers, claims, body, expectStatus, responseTable, lineNumber); + return new HttpStep(method, path, headers, claims, body, responseTable, lineNumber); } // [HTTP] METHOD /path [HTTP/x]. Method required (GET|PUT|POST|DELETE); path must start with '/'; @@ -123,7 +122,6 @@ private static bool TryParseRequestLine(string line, out string method, out stri private static void ParseDirective( string directive, List<(string, string)> claims, - ref int? expectStatus, ref string? responseTable) { int sp = directive.IndexOfAny([' ', '\t']); @@ -138,10 +136,6 @@ private static void ParseDirective( claims.Add((rest[..eq].Trim(), rest[(eq + 1)..].Trim())); } } - else if (string.Equals(keyword, "expect-status", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(rest, out var code)) expectStatus = code; - } else if (string.Equals(keyword, "response", StringComparison.OrdinalIgnoreCase)) { if (rest.Length > 0) responseTable = rest; diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 2e55446d..07386f8d 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -33,6 +33,7 @@ public sealed class TestRunner private readonly NpgsqlRestOptions _rest; private readonly TestRunnerOptions _opt; private readonly string _connString; + private readonly IReadOnlyDictionary _named; private readonly ILogger? _log; private readonly bool _logNotices; private readonly Out _out = new(); @@ -44,9 +45,9 @@ private enum Outcome { Pass, Fail, Error } // only becomes a result when it errors). Name/Message describe the assertion when Emit=true. private readonly record struct StepResult(bool Emit, Outcome Outcome, string? Message, string? Name); - // One reported test = one assertion: a boolean-returning SELECT, a DO block (passes unless it raises, - // e.g. via ASSERT), or an HTTP step with `# @expect-status`. Errors from any step are also recorded - // here so they surface and count. + // One reported test = one assertion: a boolean-returning SELECT, or a DO block (passes unless it + // raises, e.g. via ASSERT). Errors from any step (SQL or HTTP) are also recorded here so they surface + // and count. private sealed class AssertionResult { public required string Name { get; init; } @@ -71,12 +72,17 @@ private sealed class FileResult Outcome.Pass; } - public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConnectionString, ILogger? logger, bool logConnectionNotices = false) + public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConnectionString, ILogger? logger, + bool logConnectionNotices = false, IReadOnlyDictionary? namedConnections = null) { _rest = rest; _opt = opt; // Always non-pooled: a fresh physical session per test (no temp-table / GUC / prepared-statement carryover). + // In test mode baseConnectionString is already the test connection (TestRunner.ConnectionName, when set). _connString = new NpgsqlConnectionStringBuilder(baseConnectionString) { Pooling = false }.ConnectionString; + // Named ConnectionStrings entries for Setup/Teardown steps that target another connection (e.g. an + // "Admin" maintenance connection that runs create/drop database). + _named = namedConnections ?? new Dictionary(); _log = logger; _logNotices = logConnectionNotices; // Wire the ambient accessor once; null when no test flow is active → zero effect on discovery/normal ops. @@ -91,21 +97,14 @@ public async Task SetupAsync(CancellationToken ct = default) // Response-table names are validated and created per HTTP block (no pre-run permanent-table // collision check needed: writes are pg_temp-qualified and CREATE TEMP TABLE (no IF NOT EXISTS) // fails loudly on a real duplicate). - _log?.LogDebug("setup: {Commands} command(s), {Sql} sql step(s)", - _opt.Setup.Count(s => s.IsCommand), _opt.Setup.Count(s => !s.IsCommand)); + _log?.LogDebug("setup: {Count} step(s)", _opt.Setup.Count); - // Commands first (provision infra), then SqlFile/Sql (migrations/seed), in declared order within each group. - foreach (var step in _opt.Setup.Where(s => s.IsCommand)) + // Strict declared order: each step runs in the order it is written (no Command-first grouping). + // To run something first, write it first. Each Sql/SqlFile step runs on its own connection + // (ConnectionName, else the test connection); Command steps run via the OS shell. + foreach (var step in _opt.Setup) { - await RunCommandAsync(step, ct); - } - await using (var conn = new NpgsqlConnection(_connString)) - { - await conn.OpenAsync(ct); - foreach (var step in _opt.Setup.Where(s => !s.IsCommand)) - { - await RunSqlStepAsync(conn, step, ct); - } + await RunStepAsync(step, ct); } return true; } @@ -118,6 +117,35 @@ public async Task SetupAsync(CancellationToken ct = default) } } + // Runs one Setup/Teardown step: a shell Command, or a Sql/SqlFile batch on its resolved connection. + private async Task RunStepAsync(TestSetupStep step, CancellationToken ct) + { + if (step.IsCommand) + { + await RunCommandAsync(step, ct); + return; + } + var connStr = ResolveStepConnection(step); + await using var conn = new NpgsqlConnection(connStr); + await conn.OpenAsync(ct); + await RunSqlStepAsync(conn, step, ct); + } + + // A step's connection: its explicit ConnectionName (a ConnectionStrings entry), else the test connection. + private string ResolveStepConnection(TestSetupStep step) + { + if (string.IsNullOrWhiteSpace(step.ConnectionName)) + { + return _connString; + } + if (_named.TryGetValue(step.ConnectionName, out var cs)) + { + return cs; + } + throw new InvalidOperationException( + $"TestRunner Setup/Teardown step references ConnectionName '{step.ConnectionName}', which has no matching entry under 'ConnectionStrings'."); + } + /// Runs after endpoint discovery: executes the test files and (always) Teardown. Returns the process exit code. public async Task RunAsync(RoutineEndpoint[] endpoints, CancellationToken ct = default) { @@ -204,18 +232,30 @@ private async Task RunFileAsync(string file, IReadOnlyDictionary InvokeHttpStepAsync( return new StepResult(true, Outcome.Error, $"SSE endpoints are not supported in test mode: {httpName}", httpName); if (ep.Upload) return new StepResult(true, Outcome.Error, $"upload endpoints are not supported in test mode: {httpName}", httpName); + if (ep.Login || ep.Logout) + return new StepResult(true, Outcome.Error, $"login/logout endpoints are not supported in test mode (inject the principal with `# @claim` instead): {httpName}", httpName); if (ep.IsProxy) { var host = ep.ProxyHost ?? _rest.ProxyOptions?.Host; @@ -328,6 +370,13 @@ private async Task InvokeHttpStepAsync( return new StepResult(true, Outcome.Error, $"outbound proxy/HTTP-type endpoints are disallowed in test mode: {httpName}", httpName); } } + else + { + // Not fatal — the request still runs (a test may assert the 404 deliberately) — but the most + // common cause is a path typo or a missing UrlPathPrefix (default "/api"), so surface a warning. + _log?.LogWarning("no endpoint matches {Method} {Path} — the response will be a 404; check the path (including UrlPathPrefix, default \"/api\")", + step.Method, pathOnly); + } var user = BuildPrincipal(step.Claims); @@ -351,15 +400,9 @@ private async Task InvokeHttpStepAsync( await WriteResponseAsync(conn, responseTable, response, ct); - // An HTTP block is a reported assertion only when it carries `# @expect-status`; otherwise it is an - // act step (capture the response into the temp table) and produces no test of its own. - if (step.ExpectStatus.HasValue) - { - var name = $"{httpName} (expect {step.ExpectStatus.Value})"; - return response.StatusCode == step.ExpectStatus.Value - ? new StepResult(true, Outcome.Pass, null, name) - : new StepResult(true, Outcome.Fail, $"expected status {step.ExpectStatus.Value} but got {response.StatusCode} for {httpName}", name); - } + // An HTTP block is an act step: it captures the response into the temp table and produces no test + // of its own — the assertions are the boolean SELECTs that follow it. It only surfaces as a result + // when it errors (unsupported endpoint kind above, or an exception attributed by the step loop). return new StepResult(false, Outcome.Pass, null, null); } @@ -487,22 +530,11 @@ private async Task TeardownAsync(CancellationToken ct) { if (_opt.Keep || _opt.Teardown.Count == 0) return; _log?.LogDebug("teardown: {Steps} step(s)", _opt.Teardown.Count); - try - { - await using var conn = new NpgsqlConnection(_connString); - await conn.OpenAsync(ct); - foreach (var step in _opt.Teardown.Where(s => !s.IsCommand)) // SQL first - { - try { await RunSqlStepAsync(conn, step, ct); } - catch (Exception ex) { _log?.LogWarning(ex, "teardown SQL step failed"); } - } - } - catch (Exception ex) { _log?.LogWarning(ex, "teardown connection failed"); } - - foreach (var step in _opt.Teardown.Where(s => s.IsCommand)) // commands last (e.g. docker down) + // Strict declared order, best-effort: a failing step is logged and the rest still run. + foreach (var step in _opt.Teardown) { - try { await RunCommandAsync(step, ct); } - catch (Exception ex) { _log?.LogWarning(ex, "teardown command failed"); } + try { await RunStepAsync(step, ct); } + catch (Exception ex) { _log?.LogWarning(ex, "teardown step failed"); } } } @@ -545,12 +577,12 @@ private static Dictionary BuildEndpointLookup(RoutineEn return d; } - // Print the failing statement under the message (dimmed). Capped to its first line unless --verbose. + // Print the failing statement under the message (dimmed). Capped to its first line unless DetailedReport. private void WriteFailingSql(string? sql) { if (string.IsNullOrWhiteSpace(sql)) return; var text = sql.Trim(); - if (_opt.Verbose) + if (_opt.DetailedReport) { _out.Line(" " + text.Replace("\n", "\n "), ConsoleColor.DarkGray); return; @@ -564,9 +596,9 @@ private void WriteFailingSql(string? sql) private void ReportConsole(List results) { - // Counts are at assertion granularity (each boolean SELECT / DO block / `# @expect-status` HTTP - // step is one test); files are the grouping unit. A file is fail-fast, so on failure the assertions - // listed are those that ran up to and including the first failure. + // Counts are at assertion granularity (each boolean SELECT / DO block is one test); files are the + // grouping unit. A file is fail-fast, so on failure the assertions listed are those that ran up to + // and including the first failure. int passed = 0, failed = 0, errored = 0, totalAssertions = 0; foreach (var fr in results) { @@ -585,7 +617,7 @@ private void ReportConsole(List results) else if (fr.Outcome == Outcome.Pass) { _out.Line($"PASS {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)", ConsoleColor.Green); - if (_opt.Verbose) + if (_opt.DetailedReport) foreach (var a in fr.Assertions) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); } else @@ -596,7 +628,7 @@ private void ReportConsole(List results) { if (a.Outcome == Outcome.Pass) { - if (_opt.Verbose) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); + if (_opt.DetailedReport) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); continue; } var loc = a.Line is null ? "" : $" [{rel}:{a.Line}]"; @@ -606,7 +638,7 @@ private void ReportConsole(List results) } } - if (fr.Notices.Count > 0 && (_opt.Verbose || fr.Outcome != Outcome.Pass)) + if (fr.Notices.Count > 0 && (_opt.DetailedReport || fr.Outcome != Outcome.Pass)) foreach (var n in fr.Notices) _out.Line($" notice: {n}", ConsoleColor.DarkGray); } @@ -685,7 +717,7 @@ private static string FirstLine(string text) } // Fallback display name when a step has no inherent label (a boolean SELECT without a description column, - // a DO block, or a non-`# @expect-status` HTTP step that errored). + // a DO block, or an HTTP step that errored). private static string DefaultAssertionName(TestStep step) => step switch { SqlStep { IsDoBlock: true } => $"assert block (line {step.LineNumber})", diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 2ccf7a55..53b47018 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -6,6 +6,15 @@ public class TestRunnerOptions /// Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables the runner. public string FilePattern { get; set; } = ""; + /// + /// Name of a ConnectionStrings entry to run the tests against, instead of the app's main connection. + /// In test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it + /// can point at a dedicated test database. The database need not exist at startup — a Setup step + /// (e.g. create database … on a maintenance connection) can create it first. Null/empty = use the + /// app's main connection (the Phase-1 behavior). + /// + public string? ConnectionName { get; set; } = null; + /// Max concurrent test files. 0 => Environment.ProcessorCount. public int MaxParallelism { get; set; } = 0; @@ -21,8 +30,13 @@ public class TestRunnerOptions /// Skip Teardown so a failed run's state can be inspected. public bool Keep { get; set; } = false; - /// Show captured `raise notice` output for all tests, not just failed/errored ones. - public bool Verbose { get; set; } = false; + /// + /// Detailed console REPORT: list passed assertions (✓), print the full failing SQL statement, and show + /// captured `raise notice` output for passing tests too. This shapes the report only — for diagnostic + /// logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels, see + /// ). + /// + public bool DetailedReport { get; set; } = false; /// Zero tests discovered => exit 0 instead of 4. public bool AllowEmpty { get; set; } = false; @@ -79,5 +93,13 @@ public class TestSetupStep /// Working directory for (cwd-relative); null => current directory. public string? WorkingDirectory { get; set; } + /// + /// For a / step: name of the ConnectionStrings entry to run it + /// on. Use this to run maintenance statements (e.g. create database / drop database) on an + /// admin connection pointed at a maintenance database. Null = run on the test connection + /// (, or the app's main connection). Ignored for Command steps. + /// + public string? ConnectionName { get; set; } + public bool IsCommand => !string.IsNullOrWhiteSpace(Command); } diff --git a/NpgsqlRestClient/Testing/TestStep.cs b/NpgsqlRestClient/Testing/TestStep.cs index 2ffeb0ae..25434d80 100644 --- a/NpgsqlRestClient/Testing/TestStep.cs +++ b/NpgsqlRestClient/Testing/TestStep.cs @@ -49,8 +49,6 @@ public sealed class HttpStep : TestStep public IReadOnlyList<(string Name, string Value)> Claims { get; } /// Request body (verbatim), or null when none. public string? Body { get; } - /// Expected status from # @expect-status NNN, or null. - public int? ExpectStatus { get; } /// Response temp-table name override from # @response name, or null (use the configured default). public string? ResponseTable { get; } @@ -60,7 +58,6 @@ public HttpStep( IReadOnlyList<(string Name, string Value)> headers, IReadOnlyList<(string Name, string Value)> claims, string? body, - int? expectStatus, string? responseTable, int lineNumber) : base(TestStepKind.Http, lineNumber) { @@ -69,7 +66,6 @@ public HttpStep( Headers = headers; Claims = claims; Body = body; - ExpectStatus = expectStatus; ResponseTable = responseTable; } } diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index db81a5c2..f61922b2 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1439,6 +1439,14 @@ // "FilePattern": "", // + // Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection. In + // test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can + // point at a dedicated test database that a Setup step creates first (it need not exist at startup). + // Empty = use the main connection. Tip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), + // stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). + // + "ConnectionName": "", + // // Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection. // "MaxParallelism": 0, @@ -1459,9 +1467,11 @@ // "Keep": false, // - // Show captured `raise notice` output for all tests, not just failed/errored ones. + // Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show + // captured `raise notice` output for passing tests too. This shapes the report only — for diagnostic + // logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName). // - "Verbose": false, + "DetailedReport": false, // // Treat "no tests discovered" as success (exit 0) instead of exit 4. // @@ -1490,13 +1500,15 @@ } }, // - // Run-once setup, BEFORE endpoint discovery. Command steps run first (provision infra, e.g. docker), - // then SqlFile/Sql steps (migrations/seed) — declared order preserved within each group. Each entry: - // { "Command": "...", "WorkingDirectory": "..." } | { "SqlFile": "..." } | { "Sql": "..." } + // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is one of: + // { "Command": "...", "WorkingDirectory": "..." } — runs via the OS shell + // { "Sql": "..." } | { "SqlFile": "..." } — runs on the test connection; set "ConnectionName" + // to run on another ConnectionStrings entry (e.g. + // an admin connection that runs `create database`). // "Setup": [], // - // Run-once teardown, ALWAYS (best-effort), reverse order: SqlFile/Sql first, then Command. `Keep` skips it. + // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same step shapes as Setup. // "Teardown": [] }, diff --git a/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs b/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs index 09ca1252..2c8ab641 100644 --- a/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs +++ b/NpgsqlRestTests/TestRunnerTests/ParserTests/HttpFileRequestParserTests.cs @@ -89,10 +89,11 @@ public void Repeated_Claim_Type_Yields_Multiple_Values() } [Fact] - public void Expect_Status_And_Response_Directives_Are_Parsed() + public void Response_Directive_Is_Parsed_And_Unknown_Directives_Are_Ignored() { + // `@expect-status` was removed (assert on the response temp table's status column instead); + // an unknown directive is silently ignored rather than breaking the block. var s = Parse("GET /x\n# @expect-status 404\n# @response listing"); - s.ExpectStatus.Should().Be(404); s.ResponseTable.Should().Be("listing"); } diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index f5ef3c21..36ec163c 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -1,46 +1,269 @@ # Changelog v3.19.0 -## Version [3.19.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.19.0) (2026-06-30) +## Version [3.19.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.19.0) (2026-07-02) [Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.18.2...3.19.0) -Minor release adding two small, independent configuration options: a **skip glob for the SQL file source** (exclude files from endpoint discovery) and the ability to **mute an individual logger** via `Log:MinimalLevels`. +This release introduces the **SQL test runner** (`npgsqlrest --test`) — write tests for your endpoints as plain `.sql` files, invoke endpoints in-process from inside a test, and assert on both the HTTP response and the database state, all within the test's own transaction. Also included: a **skip glob for the SQL file source** and the ability to **mute an individual logger**. -## What changed +--- -### 1. `SqlFileSource.SkipPattern` — exclude files from endpoint discovery +## 1. SQL test runner (`--test`) + +``` +npgsqlrest ./config.json --test +``` + +The runner discovers `.sql` test files, executes each on its own isolated connection, and reports per-assertion results to the console (and optionally JUnit XML for CI). A test file is ordinary SQL — arrange data, call an endpoint, assert on the result: + +```sql +-- tests/get_users_excludes_caller.test.sql +begin; + +insert into app.users (id, email, name) values (100, 'x@example.com', 'Fixture'); + +/* +GET /api/get-users +# @claim user_id=1 +*/ +select (select status from _response) = 200, + 'authenticated caller gets 200'; +select (select body::jsonb @> '[{"email": "x@example.com"}]' from _response), + 'the fixture user is listed'; + +rollback; +``` + +``` +NpgsqlRest test runner — 9 file(s) +PASS tests/get_users_excludes_caller.test.sql (2 assertions, 52ms) +... +19 passed, 0 failed, 0 error(s) — 19 assertions in 9 files +``` + +### How it works + +In `--test` mode the client builds the full endpoint middleware exactly as in normal operation (endpoints from database routines and/or SQL files, authentication, custom parameters — everything), but instead of starting the web server it runs the test files and exits with a result code. Endpoint calls made from a test are **in-process**: no network, no running server — the complete endpoint pipeline (routing, authorization, parameter binding, execution, serialization) runs against a synthetic HTTP context. + +The critical property is **connection affinity**: the in-process endpoint call runs on the *test's own connection*, inside the *test's own transaction*. A test can `begin`, insert fixture rows, call an endpoint that sees those uncommitted rows, assert on the response, and `rollback` — leaving no trace. Each test file gets its own **non-pooled** physical connection (fresh session: no temp-table, GUC, or prepared-statement carryover), and files run **in parallel** (`MaxParallelism`, default = processor count). If a file never rolls back, closing its physical connection aborts the open transaction — that is the safety net. + +Test-mode invariants, applied automatically: + +- `WrapInTransaction` is forced off (the test file owns transaction control — the runner never injects `BEGIN`/`COMMIT`/`ROLLBACK`). +- Response caching is disabled (a test never sees another test's cached response). +- Code generation (HTTP files, TypeScript client, OpenAPI) is **skipped** — a test run never rewrites generated artifacts. + +### Test file anatomy + +A test file is a sequence of SQL statements and HTTP blocks, executed strictly in order. Files are executed **statement by statement** (like `psql`): each statement runs in autocommit unless the file opens its own transaction. Semicolon splitting understands line/block comments, string literals with `''` escapes, and dollar-quoted bodies — a `do $$ … $$;` block stays whole. + +**Assertions** — a reported test is one of: + +- **A boolean-returning `SELECT`.** If the first column is `boolean`, the statement is an assertion: the first row's value must be true (`false` or `null` fails; zero rows passes vacuously; only the first row is examined). The optional **second column is the assertion's name/message**, shown in the report and used as the JUnit test-case name: + ```sql + select count(*) = 3, 'exactly three users are seeded' from app.users; + ``` +- **A `do` block.** Passes unless it raises — `assert` inside a DO block raises SQLSTATE `P0004`, reported as a failure with the assert message. One DO block = one reported test (multiple `assert`s inside it are opaque to the runner): + ```sql + do $$ begin + assert app.normalize_email(' X@Y.z ') = 'x@y.z', 'should trim and lowercase'; + end $$; + ``` + +Any other statement is *arrange/act* — not counted as a test; it only surfaces if it errors. Any SQL error (other than an assert) is reported as an **error** with its SQLSTATE, message, statement text, and `file:line`. + +Failing behavior is **fail-fast per file**: after the first failed/errored assertion the rest of the file does not run (a failed DO-block assert aborts the transaction anyway). Assertions that passed before the failure are still credited. + +### HTTP blocks — invoking endpoints + +An HTTP request is embedded in a **block comment** whose first non-comment line is a request line — a single-request subset of the standard `.http` file syntax: + +```sql +/* +POST /api/create-user +Content-Type: application/json +# @claim user_id=42 +# @claim roles=admin +# @response created + +{"name": "Grace Hopper", "email": "grace@example.com"} +*/ +``` + +Syntax rules: + +- **Request line:** `[HTTP] METHOD /path[?query] [HTTP/x]` — the method is one of `GET`, `POST`, `PUT`, `DELETE` (the methods NpgsqlRest endpoints support); the path must start with `/` and must equal the endpoint's full path **including `UrlPathPrefix`** (default `/api`); the leading `HTTP` keyword and a trailing HTTP-version token are optional. A block comment whose first line is not a valid request line is an ordinary SQL comment — ignored. +- **Headers:** `Name: Value` lines after the request line. `Content-Type` is picked up for the request body. +- **Directives** (lines starting with `# @` or `// @`, placed after the request line, before the body): + - `# @claim name=value` — adds a claim to the acting principal. Repeatable, including the same claim type twice (e.g. two `roles` claims). Any `# @claim` makes the request **authenticated**; no `# @claim` means **anonymous** (an `@authorize` endpoint returns 401). Role checks and claim-to-parameter mappings (`@user_parameters`, `ParameterNameClaimsMapping`) work exactly as in production — tests exercise the real authorization path. + - `# @response name` — capture this block's response into a temp table with the given name instead of the default. +- **Body:** everything after the first **blank line**, verbatim. (Limitation: a literal `*/` inside the body ends the SQL comment early.) +- Plain `#` or `//` lines are comments; unknown `@` directives are ignored. + +An HTTP block is an **act** step, not an assertion — it captures the response and produces no test result of its own. The assertions are the SQL statements that follow it. One request per block; use multiple blocks for multiple calls. + +Endpoint kinds that cannot work meaningfully in-process are rejected with a clear error: **SSE**, **upload**, **login/logout** (inject the principal with `# @claim` instead), and **outbound proxy/HTTP-type** endpoints (tests must not call external services). A request whose path matches **no endpoint** still runs (a test may assert the 404 deliberately) but logs a **warning** — the most common cause is a path typo or a missing `/api` prefix. + +### The response temp table + +Each HTTP block's response is captured into its own **temp table** on the test's connection, created fresh (no `IF NOT EXISTS` — a duplicate name, e.g. a repeated `# @response` name, fails the test loudly). Default columns: + +| Column | Type | Content | +|---|---|---| +| `status` | `int` | HTTP status code | +| `body` | `text` | response body (cast to `::jsonb` to assert on JSON) | +| `content_type` | `text` | response content type | +| `headers` | `jsonb` | response headers | +| `is_success` | `boolean` | true for 2xx | + +Naming: a file with **one** HTTP block uses `ResponseTempTable.Name` (default **`_response`**); a file with **two or more** uses `ResponseTempTable.MultiNamePattern` (default **`_response_{n}`**) where `{n}` is the block's 1-based position — `_response_1`, `_response_2`, … A block with `# @response name` uses that name instead (it still counts in the numbering of the others). Column names are configurable; setting one to null/empty omits that column. + +```sql +select (select body::jsonb ->> 'email' from _response) = 'grace@example.com', + 'email is normalized to lowercase'; +``` + +### Setup and Teardown + +Run-once steps around the whole test session. **`Setup` runs before endpoint discovery** (so it can create/migrate the very schema the endpoints are built from); **`Teardown` always runs** at the end — even when tests fail or Setup itself fails (best-effort; `Keep: true` skips it to let you inspect state). Steps execute in the **exact order written**; each entry is one of: + +- `{ "Sql": "..." }` — inline SQL, +- `{ "SqlFile": "path" }` — a SQL file (executed statement by statement, like test files), +- `{ "Command": "...", "WorkingDirectory": "..." }` — a shell command (e.g. `docker compose up -d`, an external migration tool). Non-zero exit fails Setup. + +`Sql`/`SqlFile` steps run on the test connection by default, or on any named `ConnectionStrings` entry via a per-step `"ConnectionName"` — which enables maintenance operations like `create database` without the runner ever issuing DDL on its own. + +### A dedicated test database + +`TestRunner.ConnectionName` points the whole test session — endpoint type-checking (SQL-file Describe), endpoint execution, and the tests — at a named `ConnectionStrings` entry instead of the app's main connection. That database **need not exist at startup**: it is never opened before Setup, so the first Setup step can create it. + +```jsonc +"ConnectionStrings": { + "Default": "Host={PGHOST};Database=appdb;...", // the real app DB — untouched by tests + "Admin": "Host={PGHOST};Database=postgres;...", // maintenance (needs CREATEDB) + "Test": "Host={PGHOST};Database=app_test_{rnd6};..." // the throwaway test DB +}, +"TestRunner": { + "ConnectionName": "Test", + "FilePattern": "./tests/**/*.test.sql", + "Setup": [ + { "Sql": "create database app_test_{rnd6}", "ConnectionName": "Admin" }, + { "SqlFile": "./migrations/schema.sql" } // runs on "Test" + ], + "Teardown": [ + { "Sql": "drop database app_test_{rnd6} with (force)", "ConnectionName": "Admin" } + ] +} +``` + +**`{rnd1}`…`{rnd10}`** are random lowercase tokens (length = the digit), generated once per run and substituted everywhere `{ENV}` placeholders work — connection strings, Setup/Teardown SQL, and Commands. The same token yields the same value across the whole config, so the connection string, the `create`, and the `drop` all name the same database; concurrent suites on a shared server can't collide. (Trade-off: a hard crash that skips Teardown orphans that run's database. A static name with a leading `drop database if exists … with (force);` in Setup is the self-healing alternative.) + +The same Setup/Teardown machinery covers the neighboring workflows with no additional features: clone a prepared **template** (`create database … template app_template` — near-instant), start a **Docker** Postgres (`Command` steps: `docker run` → wait for `pg_isready` → migrate; `docker rm -f` in Teardown), or run an **external migrator** (EF Core, Django, Flyway) as a `Command`. + +### Reporting + +Results are **per assertion** (like pgTAP/xUnit — each boolean `SELECT` / DO block is one test), grouped per file: + +``` +PASS tests/login_succeeds.test.sql (3 assertions, 50ms) +FAIL tests/get_users.test.sql (49ms) + ✗ the caller is excluded — 2 of 3 users listed [tests/get_users.test.sql:17] + select (select jsonb_array_length(body::jsonb) from _response) = 2, … + +18 passed, 1 failed, 0 error(s) — 19 assertions in 9 files +``` + +Failures show the assertion name, `file:line`, and the failing statement. `DetailedReport: true` additionally lists passed assertions (`✓`), full failing SQL, and captured `raise notice` output for passing tests (notices always show under failing tests). This shapes the console report only — it is distinct from raising the `NpgsqlRestTest` *log* level, which controls diagnostics (see Logging below). A file with no recognizable assertions is flagged (yellow) rather than silently counted. Colors are disabled automatically when output is redirected. + +**JUnit XML** (`JUnitOutput: "path.xml"`): one `` per assertion (name = the assertion message, classname = the file), ``/`` with message and `file:line`, captured notices in ``, files without assertions marked `` — works with any CI dashboard. + +**Exit codes:** `0` all passed · `1` at least one failure · `2` at least one error (SQL error, timeout, unsupported endpoint) · `3` setup/configuration error · `4` no test files found (`AllowEmpty: true` turns this into `0`). + +### Logging + +The runner logs through its own channel — **`NpgsqlRestTest`** (configurable via `TestRunner.LoggerName`) — leveled independently under `Log:MinimalLevels` (defaults to `Information` when absent, i.e. quiet): + +- **Verbose** — every SQL statement and every HTTP invocation (`GET /api/x → 200, captured into "_response"`), +- **Debug** — discovery, per-file parse results, per-file outcomes, Setup/Teardown steps, degree of parallelism, +- **Warning** — a request that matches no endpoint, failed teardown steps, +- `raise notice`/`warning` from the database — logged **by their severity**, tagged with the test file that emitted them. + +```jsonc +"Log": { "MinimalLevels": { "NpgsqlRest": "Off", "NpgsqlRestClient": "Off", "NpgsqlRestTest": "Verbose" } } +``` + +### Configuration reference (`TestRunner` section) + +```jsonc +"TestRunner": { + "FilePattern": "", // glob selecting test files (same engine as SqlFileSource); empty disables + "ConnectionName": "", // ConnectionStrings entry to test against; empty = the main connection + "MaxParallelism": 0, // concurrent test files; 0 = processor count + "FailFast": false, // stop scheduling new files after the first failure (in-flight finish) + "PerTestTimeout": "30s", // per-file timeout: "30s", "5m", "1h", plain seconds, "hh:mm:ss"; 0 disables + "JUnitOutput": null, // optional path for a JUnit XML report + "Keep": false, // skip Teardown (inspect state after a failed run) + "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests + "AllowEmpty": false, // exit 0 instead of 4 when no tests are found + "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) + "ResponseTempTable": { + "Name": "_response", // table name when a file has ONE HTTP block + "MultiNamePattern": "_response_{n}",// name pattern for 2+ blocks; {n} = 1-based block position + "Columns": { // response → column mapping; null/empty omits the column + "Status": "status", "Body": "body", "ContentType": "content_type", + "Headers": "headers", "IsSuccess": "is_success" + } + }, + "Setup": [], // run-once, BEFORE endpoint discovery, in written order + "Teardown": [] // run-once, ALWAYS, in written order (Keep skips) +} +``` + +A practical convention is to keep the `TestRunner` block (and quiet log levels) in a separate `test-config.json` layered on only for test runs: `npgsqlrest ./config.json ./test-config.json --test`. + +### Project layout + +Two equally supported conventions — the difference is just the globs: + +- **Co-located:** `sql/get_users.sql` + `sql/get_users.test.sql`. Pair the endpoint glob with the new `SqlFileSource.SkipPattern` (below) so test files are never exposed as endpoints. +- **Separate tree:** endpoints in `sql/`, tests in `tests/` (named by scenario). The globs never overlap, so no `SkipPattern` is needed. + +--- + +## 2. `SqlFileSource.SkipPattern` — exclude files from endpoint discovery New option **`SkipPattern`** on the SQL file source (config key `NpgsqlRest:SqlFileSource:SkipPattern`, default **`"*.test.sql"`**). Files whose full path matches this glob are excluded from endpoint discovery: a `.sql` file becomes an endpoint only when it matches **`FilePattern`** *and* does **not** match **`SkipPattern`**. -This lets you keep auxiliary `.sql` files that live alongside your endpoint files — for example co-located SQL files following the `*.test.sql` naming convention — without them being turned into HTTP endpoints. The pattern uses the same glob engine and semantics as `FilePattern` (`*.ext` matches by suffix). Set it to an empty string (`""`) to disable the exclusion. +This is what makes the co-located test layout safe: without it, a test file's `/* GET /x */` block would be read as an HTTP annotation and exposed as an endpoint. The pattern uses the same glob engine and semantics as `FilePattern` (`*.ext` matches by suffix). Set it to an empty string (`""`) to disable the exclusion. > **Behavior change:** the default is `"*.test.sql"`, so files matching that suffix are **no longer exposed** as endpoints out of the box. If you previously relied on serving `*.test.sql` files, set `SkipPattern` to `""` to restore the old behavior. -### 2. Mute an individual logger with `"Off"` in `Log:MinimalLevels` +## 3. Mute an individual logger with `"Off"` in `Log:MinimalLevels` Each entry under **`Log:MinimalLevels`** now accepts **`"Off"`** (aliases **`"None"`** and **`"Silent"`**, case-insensitive) to **fully silence** that logger. Previously the only accepted values were the Serilog levels `Verbose…Fatal`, and there was no way to turn a logger off completely. - `"Off"` / `"None"` / `"Silent"` → the logger emits nothing (implemented as a minimum level above `Fatal`, since Serilog's `LogEventLevel` has no native "off"). - `null`, an omitted key, or an unrecognized value → unchanged: the logger keeps its built-in default level. -Each named logger is controlled independently, so you can, for example, mute the `System`/`Microsoft` framework logs entirely while keeping the application loggers at `Information`: +Each named logger is controlled independently — e.g. mute the application loggers entirely while watching the test runner: ```json "Log": { "MinimalLevels": { - "NpgsqlRest": "Information", - "NpgsqlRestClient": "Information", - "System": "Off", - "Microsoft": "Off" + "NpgsqlRest": "Off", + "NpgsqlRestClient": "Off", + "NpgsqlRestTest": "Verbose" } } ``` ## Notes -- Both options are wired through the client configuration: `appsettings.json`, the JSON-schema descriptions, and the `--config` template (which continues to match `appsettings.json` byte-for-byte). -- Fully additive apart from the `SkipPattern` default noted above; no API changes. +- The test runner's core hooks are additive and inert outside `--test`: an ambient-connection accessor on the endpoint pipeline (null by default) and response headers on the internal invocation result. Normal server operation is unchanged. +- Test files run **statement by statement** (the client operates Npgsql with SQL rewriting disabled — one statement per command), which is also `psql`'s default execution model; explicit `begin`/`commit`/`rollback` in a file work as ordinary statements. `Setup`/`Teardown` `Sql`/`SqlFile` steps execute the same way — which is why `create database` works as a plain step. +- All new options are wired through the client configuration: `appsettings.json`, the JSON-schema descriptions, and the `--config` template. +- Working examples: [`examples/19_testing_basic`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/19_testing_basic) (co-located layout, multi-step scenario files) and [`examples/20_testing_newdb`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/20_testing_newdb) (separate `tests/` tree, one test per file, fresh test database per run, deferrable-constraint fixtures, authorization + user parameters). ## Tests -Full test suite green (2334). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). +Full test suite green (2334), including 33 parser unit tests for the test-file and HTTP-block parsers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). Both documentation examples verified end-to-end against live PostgreSQL (19 assertions in 9 files for example 20, including create-database-per-run with a `{rnd}` name and teardown drop). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). From 58ae087ee05d12b1d1a4bf5f4e0c87fef53f6f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 17:24:24 +0200 Subject: [PATCH 03/14] feat(test-runner): script includes, named steps, per-file setup/teardown/connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Script reuse — \i / \ir includes (psql syntax, paste semantics): - `\i path` (cwd-relative) / `\ir path` (relative to the including file) on its own line splices the included file's content in place, as if pasted: SQL statements, assertions (attributed to the included file), HTTP blocks (participate in _response_{n} numbering), and header annotations (a shared annotation "profile" attaches with one include line). - Recognized only between statements (never inside strings/dollar-quotes; no statement fragments — use functions/views). Nested, cycle-safe, depth-capped. Works in Setup/Teardown SqlFile steps. Errors report the included file's name and line. Quoted paths and a trailing ';' accepted; other backslash lines pass through to PostgreSQL untouched. Named steps (TestRunner.Steps): - name -> step dictionary (same idiom as ConnectionStrings/Profiles); Setup/Teardown arrays mix name references and inline step objects; unknown name is a configuration error (exit 3). Step logs show phase + name + resolved code: `setup step CreateTemplate: create database app_template_abc`. Per-file header annotations (leading -- comments, before the first statement): - `-- @setup Name [Name ...]` runs named steps before the file - `-- @teardown Name [Name ...]` runs after the file — always, best-effort, after the file's connection closes (so `drop database ... with (force)` works), on the run token (survives per-test timeout) - `-- @connection Name` runs the file, including its in-process endpoint calls, on a named ConnectionStrings entry - Repeatable; names whitespace- or comma-separated; accumulate and execute in written order (teardown not reversed); setup fail-fast, teardown best-effort. Together these enable per-test database isolation: clone a template, run in the clone, drop it (deterministic sequence ids). {rnd} indexed instances: - {rndN_1}..{rndN_9} are independent random tokens per length (all stable per run) for when several distinct same-length names are needed. Changelog updated (paste-model includes, named steps, per-file annotations, indexed tokens, example 21). Config synced across appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs. Full suite green (2351) incl. 50 parser tests; examples 19/20/21 verified end-to-end (21: two parallel per-test clones from one template, shared annotation profile via \ir, Command step with {rnd} substitution). --- NpgsqlRestClient/Builder.cs | 50 ++++- NpgsqlRestClient/Config.cs | 9 +- NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 7 +- NpgsqlRestClient/ConfigTemplate.cs | 17 +- NpgsqlRestClient/Program.cs | 13 +- NpgsqlRestClient/Testing/SqlTestFileParser.cs | 63 +++++- NpgsqlRestClient/Testing/TestFileLoader.cs | 168 +++++++++++++++ NpgsqlRestClient/Testing/TestRunner.cs | 151 ++++++++++++-- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 13 +- NpgsqlRestClient/Testing/TestStep.cs | 25 +++ NpgsqlRestClient/appsettings.json | 17 +- .../TestFileHeaderAndIncludeTests.cs | 196 ++++++++++++++++++ changelog/v3.19.0.md | 92 ++++++-- 14 files changed, 764 insertions(+), 58 deletions(-) create mode 100644 NpgsqlRestClient/Testing/TestFileLoader.cs create mode 100644 NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 882ba793..f6be212f 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3094,6 +3094,18 @@ public TestRunnerOptions BuildTestRunnerOptions() } } + // Named step registry (name → step), referenced by name from Setup/Teardown and test file headers. + foreach (var child in section.GetSection("Steps").GetChildren()) + { + if (string.IsNullOrWhiteSpace(child.Key)) continue; + var step = ReadStep(child); + if (step is not null) + { + step.Name = child.Key; + opt.Steps[child.Key] = step; + } + } + opt.Setup = ReadTestSteps(section.GetSection("Setup")); opt.Teardown = ReadTestSteps(section.GetSection("Teardown")); return opt; @@ -3106,21 +3118,43 @@ public TestRunnerOptions BuildTestRunnerOptions() return string.IsNullOrWhiteSpace(s.Value) ? null : s.Value; } + TestSetupStep? ReadStep(IConfigurationSection child) + { + var step = new TestSetupStep + { + Sql = _config.GetConfigStr("Sql", child), + SqlFile = _config.GetConfigStr("SqlFile", child), + Command = _config.GetConfigStr("Command", child), + WorkingDirectory = _config.GetConfigStr("WorkingDirectory", child), + ConnectionName = _config.GetConfigStr("ConnectionName", child), + }; + return step.Sql is not null || step.SqlFile is not null || step.Command is not null ? step : null; + } + List ReadTestSteps(IConfigurationSection stepsSection) { var list = new List(); if (stepsSection.Exists() is false) return list; foreach (var child in stepsSection.GetChildren()) { - var step = new TestSetupStep + // A string element is a reference to a named step from the Steps registry; an object element + // is an inline step. Both can be mixed in the same array. + if (string.IsNullOrWhiteSpace(child.Value) is false) { - Sql = _config.GetConfigStr("Sql", child), - SqlFile = _config.GetConfigStr("SqlFile", child), - Command = _config.GetConfigStr("Command", child), - WorkingDirectory = _config.GetConfigStr("WorkingDirectory", child), - ConnectionName = _config.GetConfigStr("ConnectionName", child), - }; - if (step.Sql is not null || step.SqlFile is not null || step.Command is not null) + var name = child.Value.Trim(); + if (opt.Steps.TryGetValue(name, out var named)) + { + list.Add(named); + } + else + { + throw new InvalidOperationException( + $"TestRunner Setup/Teardown references unknown step '{name}' — define it under TestRunner:Steps."); + } + continue; + } + var step = ReadStep(child); + if (step is not null) { list.Add(step); } diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index 2678f8cf..ba1cf919 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -109,9 +109,16 @@ public void Build(string[] args, string[] skip) // process and stable across the whole config — usable wherever {ENV} placeholders are // (connection strings, TestRunner Setup/Teardown). E.g. a test DB named per run: // "ConnectionStrings:Test": "...;Database=app_test_{rnd6};..." + "create database app_test_{rnd6}". + // When several DISTINCT tokens of the same length are needed, indexed instances {rndN_1}..{rndN_9} + // are each independent (e.g. {rnd3}, {rnd3_1} and {rnd3_2} are three different 3-char tokens). for (int n = 1; n <= 10; n++) { - EnvDict[string.Concat("rnd", n.ToString(System.Globalization.CultureInfo.InvariantCulture))] = RandomToken(n); + var nStr = n.ToString(System.Globalization.CultureInfo.InvariantCulture); + EnvDict[string.Concat("rnd", nStr)] = RandomToken(n); + for (int m = 1; m <= 9; m++) + { + EnvDict[string.Concat("rnd", nStr, "_", m.ToString(System.Globalization.CultureInfo.InvariantCulture))] = RandomToken(n); + } } } diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index b47ec6da..f6e6a7f6 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1123,6 +1123,7 @@ private static JsonObject GetTestRunnerDefaults() ["IsSuccess"] = "is_success" } }, + ["Steps"] = new JsonObject(), ["Setup"] = new JsonArray(), ["Teardown"] = new JsonArray() }; diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 959d8a45..01f0031f 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -248,7 +248,7 @@ public static partial class ConfigSchemaGenerator ["Stats:ActivityPath"] = "Path for current database activity.\nReturns data from pg_stat_activity showing active sessions, queries, and wait events.\nSecurity Consideration: Shows currently running queries which may contain sensitive data.", ["TestRunner"] = "SQL test runner. Invoked with the `--test` command-line flag (otherwise inert).\nDiscovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints in-process from an embedded `/* GET /path */` block (response captured into a temp table), and asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks.\nExit codes: 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found.", ["TestRunner:FilePattern"] = "Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables discovery.\nCo-located convention: app.sql (endpoint) next to app.test.sql (test).", - ["TestRunner:ConnectionName"] = "Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection.\nIn test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can point at a dedicated test database that a Setup step creates first (it need not exist at startup). Empty = use the main connection.\nTip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}).", + ["TestRunner:ConnectionName"] = "Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection.\nIn test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can point at a dedicated test database that a Setup step creates first (it need not exist at startup). Empty = use the main connection.\nTip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). Need several distinct tokens of the same length? Indexed instances {rndN_1}..{rndN_9} are each independent.", ["TestRunner:MaxParallelism"] = "Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection.", ["TestRunner:FailFast"] = "Stop scheduling new tests after the first failure/error (in-flight tests still finish).", ["TestRunner:PerTestTimeout"] = "Per-test timeout. Accepts \"30s\", \"5m\", \"1h\", a plain number of seconds, or \"hh:mm:ss\". 0 disables.", @@ -260,8 +260,9 @@ public static partial class ConfigSchemaGenerator ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", ["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.", - ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is one of: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).", - ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), in the EXACT order written. Same step shapes as Setup (e.g. a `drop database` on an admin connection). The `Keep` option skips it.", + ["TestRunner:Steps"] = "Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in Setup/Teardown, or from an individual test file's leading header comments:\n-- @setup StepName [StepName ...] (runs before that file); -- @teardown StepName [StepName ...] (runs after that file, always, best-effort); -- @connection Name (runs that file on a named ConnectionStrings entry).\nAnnotations are repeatable; names may be whitespace- or comma-separated and run in the order written.\nTest files can also reuse SQL scripts in place with psql-style includes: `\\i file` (cwd-relative) or `\\ir file` (relative to the including file) — executed on the test's connection, inside its transaction.", + ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is a step NAME from \"Steps\", or an inline object: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).", + ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), in the EXACT order written. Same entries as Setup (step names or inline objects, e.g. a `drop database` on an admin connection). The `Keep` option skips it.", ["CommandRetryOptions"] = "Command retry strategies and options for client and middleware commands.", ["CommandRetryOptions:Strategies:default:RetrySequenceSeconds"] = "Retry sequence in seconds. Accepts decimal numbers (0.25 is quarter of a second). The length of the array determines the maximum number of retries.", ["CommandRetryOptions:Strategies:default:ErrorCodes"] = "Error codes that will trigger a retry when executing a command. See https://www.postgresql.org/docs/current/errcodes-appendix.html", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index fe68090e..f0fe916f 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1453,6 +1453,7 @@ public static partial class ConfigSchemaGenerator // point at a dedicated test database that a Setup step creates first (it need not exist at startup). // Empty = use the main connection. Tip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), // stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). + // Need several distinct tokens of the same length? Indexed instances {rndN_1}..{rndN_9} are each independent. // "ConnectionName": "", // @@ -1509,7 +1510,19 @@ public static partial class ConfigSchemaGenerator } }, // - // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is one of: + // Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in + // Setup/Teardown below, or from an individual test file's leading header comments: + // -- @setup StepName [StepName ...] runs before that file + // -- @teardown StepName [StepName ...] runs after that file (always, best-effort) + // -- @connection Name runs that file on a named ConnectionStrings entry + // Annotations are repeatable; names may be whitespace- or comma-separated and run in the order written. + // Test files can also reuse SQL scripts in place with psql-style includes: `\i file` (cwd-relative) or + // `\ir file` (relative to the including file) — executed on the test's connection, inside its transaction. + // + "Steps": {}, + // + // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is a step + // NAME from "Steps" above, or an inline step object: // { "Command": "...", "WorkingDirectory": "..." } — runs via the OS shell // { "Sql": "..." } | { "SqlFile": "..." } — runs on the test connection; set "ConnectionName" // to run on another ConnectionStrings entry (e.g. @@ -1517,7 +1530,7 @@ public static partial class ConfigSchemaGenerator // "Setup": [], // - // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same step shapes as Setup. + // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same entries as Setup. // "Teardown": [] }, diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 5444b9b7..090a2e22 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -500,7 +500,18 @@ var logTestNotices = options.LogConnectionNoticeEvents; options.LogConnectionNoticeEvents = false; - testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices, builder.BuildTestRunnerConnectionStrings()); + try + { + testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices, builder.BuildTestRunnerConnectionStrings()); + } + catch (Exception ex) + { + // Configuration error (e.g. a Setup/Teardown entry referencing an unknown named step). + new Out().Line($"Test runner configuration error: {ex.Message}", ConsoleColor.Red); + builder.TestLogger?.LogError(ex, "Test runner configuration error"); + Environment.ExitCode = NpgsqlRestClient.Testing.TestRunner.ExitConfig; + return; + } if (await testRunner.SetupAsync() is false) { Environment.ExitCode = NpgsqlRestClient.Testing.TestRunner.ExitConfig; diff --git a/NpgsqlRestClient/Testing/SqlTestFileParser.cs b/NpgsqlRestClient/Testing/SqlTestFileParser.cs index bc8bc385..029501e6 100644 --- a/NpgsqlRestClient/Testing/SqlTestFileParser.cs +++ b/NpgsqlRestClient/Testing/SqlTestFileParser.cs @@ -3,16 +3,46 @@ namespace NpgsqlRestClient.Testing; /// -/// Splits a .test.sql file into an ordered list of steps (SQL statements and embedded HTTP requests), -/// preserving interleaving. A char-by-char state machine handles ';' statement splitting, line/block -/// comments, single quotes (with '' escapes), and dollar-quoted strings / DO blocks (so semicolons inside -/// them don't split). A block comment whose first line is a request line becomes an ; -/// any other comment is ignored. +/// Splits a .test.sql file into an ordered list of steps (SQL statements, embedded HTTP requests, and +/// include lines), preserving interleaving. A char-by-char state machine handles ';' statement splitting, +/// line/block comments, single quotes (with '' escapes), and dollar-quoted strings / DO blocks (so +/// semicolons inside them don't split). A block comment whose first line is a request line becomes an +/// ; any other comment is ignored. A line starting with \i/\ir between +/// statements becomes an (expanded by ). /// public static class SqlTestFileParser { private enum State { Normal, BlockComment, SingleQuote, DollarQuote } + /// + /// Parses one line as an include: \i path (cwd-relative) or \ir path (relative to the + /// including file). The path may be single-quoted; a trailing ';' is forgiven. Returns false for any + /// other line (including other backslash commands). + /// + internal static bool TryParseIncludeLine(string line, out string path, out bool relativeToFile) + { + path = ""; + relativeToFile = false; + var t = line.Trim(); + bool relative = t.StartsWith("\\ir", StringComparison.OrdinalIgnoreCase) + && (t.Length == 3 || char.IsWhiteSpace(t[3])); + bool plain = !relative && t.StartsWith("\\i", StringComparison.OrdinalIgnoreCase) + && (t.Length == 2 || char.IsWhiteSpace(t[2])); + if (!relative && !plain) return false; + + var p = t[(relative ? 3 : 2)..].Trim(); + if (p.EndsWith(';')) p = p[..^1].TrimEnd(); // forgive a trailing ; + if (p.Length > 1 && p[0] == '\'' && p[^1] == '\'') + { + p = p[1..^1]; // psql-style quoted path + } + if (p.Length == 0) return false; + + path = p; + relativeToFile = relative; + return true; + } + public static List Parse(string content) { var steps = new List(); @@ -63,6 +93,29 @@ void FlushSql() switch (state) { case State.Normal: + // include: \i (cwd-relative) or \ir (relative to the including file) — + // psql semantics. Recognized only between statements (pending statement is blank), so a + // backslash inside SQL text/strings/dollar-quotes is never misread. Consumes the line. + if (c == '\\' && !stmtHasContent) + { + int lineEnd = i; + while (lineEnd < len && content[lineEnd] != '\n') lineEnd++; + var includeLine = content[i..lineEnd].TrimEnd('\r').TrimEnd(); + if (TryParseIncludeLine(includeLine, out var path, out var relative)) + { + steps.Add(new IncludeStep(path, relative, currentLine)); + i = lineEnd; // consume the include line; the newline is handled by the main loop + continue; + } + // Not \i/\ir: keep just the backslash as SQL text and let normal parsing continue + // (';' still splits; PostgreSQL will report the stray backslash clearly). + stmt.Append(c); + MarkContent(); + lastToken = ""; + lastTokenIsWord = false; + i++; + continue; + } // line comment: -- (skip to end of line, never an HTTP step) if (c == '-' && i + 1 < len && content[i + 1] == '-') { diff --git a/NpgsqlRestClient/Testing/TestFileLoader.cs b/NpgsqlRestClient/Testing/TestFileLoader.cs new file mode 100644 index 00000000..310bc45f --- /dev/null +++ b/NpgsqlRestClient/Testing/TestFileLoader.cs @@ -0,0 +1,168 @@ +namespace NpgsqlRestClient.Testing; + +/// +/// Per-file header annotations, read from the LEADING line comments of a test file (scanning stops at the +/// first line that is neither blank, a -- comment, nor an include): +/// +/// -- @setup StepName [StepName ...] (run before this file; names from TestRunner:Steps) +/// -- @teardown StepName [StepName ...] (run after this file, always, best-effort) +/// -- @connection Name (run this file on a named ConnectionStrings entry) +/// +/// Annotations are repeatable and names accumulate in written order; multiple names on one line may be +/// separated by whitespace or commas (`@setup A B` == `@setup A, B` — the NpgsqlRest annotation idiom). +/// Other comment lines are ignored, so ordinary file documentation can freely surround the annotations. +/// An include (\i/\ir) in the header region is scanned AS IF PASTED: annotations in the +/// included file's own leading comments count, and the header ends where the pasted content's first +/// statement would be — so a shared "profile" file of annotations can be attached with one include line. +/// +public sealed class TestFileHeader +{ + public List Setup { get; } = []; + public List Teardown { get; } = []; + public string? ConnectionName { get; private set; } + + public static TestFileHeader Parse(string content, string? sourceFile = null) + { + var header = new TestFileHeader(); + var visited = new HashSet(StringComparer.Ordinal); + if (sourceFile is not null) + { + visited.Add(Path.GetFullPath(sourceFile)); + } + Scan(content, sourceFile, header, visited, depth: 0); + return header; + } + + // Scans the leading header region; returns false when a statement ended the header (so an including + // file must stop scanning too — paste semantics), true when the whole content was header material. + private static bool Scan(string content, string? sourceFile, TestFileHeader header, HashSet visited, int depth) + { + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine.TrimEnd('\r').Trim(); + if (line.Length == 0) continue; + + if (line.StartsWith("--")) + { + var comment = line[2..].TrimStart(); + if (!comment.StartsWith('@')) continue; + + // Names are separated by whitespace and/or commas (CSV works: `@setup A, B` == `@setup A B`). + var tokens = comment[1..].Split([' ', '\t', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) continue; + + if (string.Equals(tokens[0], "setup", StringComparison.OrdinalIgnoreCase)) + { + header.Setup.AddRange(tokens[1..]); + } + else if (string.Equals(tokens[0], "teardown", StringComparison.OrdinalIgnoreCase)) + { + header.Teardown.AddRange(tokens[1..]); + } + else if (string.Equals(tokens[0], "connection", StringComparison.OrdinalIgnoreCase)) + { + header.ConnectionName = tokens[1]; + } + // any other @word is an ordinary comment — ignored + continue; + } + + if (SqlTestFileParser.TryParseIncludeLine(line, out var incPath, out var relative)) + { + if (depth >= 16) + { + throw new InvalidOperationException($"include nesting deeper than 16 at '{incPath}' while reading header annotations"); + } + var baseDir = relative && sourceFile is not null + ? Path.GetDirectoryName(Path.GetFullPath(sourceFile))! + : Environment.CurrentDirectory; + var fullPath = Path.GetFullPath(incPath, baseDir); + if (!File.Exists(fullPath)) + { + throw new InvalidOperationException($"include not found: '{incPath}' resolved to '{fullPath}' while reading header annotations"); + } + if (!visited.Add(fullPath)) + { + throw new InvalidOperationException($"circular include: '{fullPath}' while reading header annotations"); + } + var keepScanning = Scan(File.ReadAllText(fullPath), fullPath, header, visited, depth + 1); + visited.Remove(fullPath); + if (!keepScanning) + { + return false; // the pasted content contained a statement — the header ends there + } + continue; // comments/annotations-only include — the header continues in this file + } + + return false; // first statement (or HTTP block start) ends the header + } + return true; + } +} + +/// +/// Loads parsed steps with \i/\ir includes expanded in place — paste semantics: each include +/// line is replaced by the included file's parsed steps (SQL statements and HTTP blocks alike), recursively +/// (cycle-safe, depth-capped). Included steps carry so failures are +/// attributed to the included file and line. +/// +public static class TestFileLoader +{ + private const int MaxIncludeDepth = 16; + + /// + /// Parses and expands include steps. is the + /// full path of the file the content came from (used to resolve \ir and for attribution); null + /// for inline SQL, in which case \ir falls back to cwd-relative like \i. + /// + public static List LoadSteps(string content, string? sourceFile) + { + var visited = new HashSet(StringComparer.Ordinal); + if (sourceFile is not null) + { + visited.Add(Path.GetFullPath(sourceFile)); + } + return Expand(SqlTestFileParser.Parse(content), sourceFile, isIncluded: false, visited, depth: 0); + } + + private static List Expand(List steps, string? sourceFile, bool isIncluded, HashSet visited, int depth) + { + var result = new List(steps.Count); + foreach (var step in steps) + { + if (step is not IncludeStep inc) + { + if (isIncluded) + { + step.SourceFile = sourceFile; + } + result.Add(step); + continue; + } + + var from = isIncluded ? sourceFile : null; + var baseDir = inc.RelativeToFile && sourceFile is not null + ? Path.GetDirectoryName(Path.GetFullPath(sourceFile))! + : Environment.CurrentDirectory; + var fullPath = Path.GetFullPath(inc.Path, baseDir); + + if (depth >= MaxIncludeDepth) + { + throw new InvalidOperationException($"include nesting deeper than {MaxIncludeDepth} at '{inc.Path}' (line {inc.LineNumber}{(from is null ? "" : $" of {from}")})"); + } + if (!File.Exists(fullPath)) + { + throw new InvalidOperationException($"include not found: '{inc.Path}' resolved to '{fullPath}' (line {inc.LineNumber}{(from is null ? "" : $" of {from}")})"); + } + if (!visited.Add(fullPath)) + { + throw new InvalidOperationException($"circular include: '{fullPath}' (line {inc.LineNumber}{(from is null ? "" : $" of {from}")})"); + } + + var inner = SqlTestFileParser.Parse(File.ReadAllText(fullPath)); + result.AddRange(Expand(inner, fullPath, isIncluded: true, visited, depth + 1)); + visited.Remove(fullPath); + } + return result; + } +} diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 07386f8d..4f27bcc0 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -55,6 +55,8 @@ private sealed class AssertionResult public Outcome Outcome { get; init; } public string? Message { get; init; } public string? Sql { get; init; } + /// Full path of the included file this assertion came from (via \i/\ir); null = the test file. + public string? SourceFile { get; init; } } // A test file groups its assertions; the file's outcome is the worst of them (Error > Fail > Pass). @@ -104,7 +106,7 @@ public async Task SetupAsync(CancellationToken ct = default) // (ConnectionName, else the test connection); Command steps run via the OS shell. foreach (var step in _opt.Setup) { - await RunStepAsync(step, ct); + await RunStepAsync(step, "setup", ct); } return true; } @@ -118,17 +120,18 @@ public async Task SetupAsync(CancellationToken ct = default) } // Runs one Setup/Teardown step: a shell Command, or a Sql/SqlFile batch on its resolved connection. - private async Task RunStepAsync(TestSetupStep step, CancellationToken ct) + // `phase` ("setup" | "teardown") is only used to label the step's log lines. + private async Task RunStepAsync(TestSetupStep step, string phase, CancellationToken ct) { if (step.IsCommand) { - await RunCommandAsync(step, ct); + await RunCommandAsync(step, phase, ct); return; } var connStr = ResolveStepConnection(step); await using var conn = new NpgsqlConnection(connStr); await conn.OpenAsync(ct); - await RunSqlStepAsync(conn, step, ct); + await RunSqlStepAsync(conn, step, phase, ct); } // A step's connection: its explicit ConnectionName (a ConnectionStrings entry), else the test connection. @@ -209,9 +212,76 @@ private async Task RunFileAsync(string file, IReadOnlyDictionary TimeSpan.Zero ? (int)Math.Ceiling(_opt.PerTestTimeout.TotalSeconds) : 0; + // Header annotations (leading -- comments): per-file setup/teardown step names + connection override. + string content; + TestFileHeader header; try { - conn = new NpgsqlConnection(_connString); + content = await File.ReadAllTextAsync(file, ct); + header = TestFileHeader.Parse(content, file); + } + catch (Exception ex) + { + result.Assertions.Add(new AssertionResult { Name = "file read error", Outcome = Outcome.Error, Message = ex.Message }); + result.ElapsedMs = sw.ElapsedMilliseconds; + return result; + } + + // `-- @connection Name` runs this file (and its in-process endpoint calls) on a named connection — + // e.g. a per-test database that a `-- @setup` step below creates from a template. + string connString = _connString; + if (header.ConnectionName is not null) + { + if (_named.TryGetValue(header.ConnectionName, out var cs)) + { + connString = cs; + } + else + { + result.Assertions.Add(new AssertionResult + { + Name = $"-- @connection {header.ConnectionName}", + Outcome = Outcome.Error, + Message = $"'{header.ConnectionName}' has no matching entry under 'ConnectionStrings'", + }); + result.ElapsedMs = sw.ElapsedMilliseconds; + return result; + } + } + + // Per-file setup steps — BEFORE the file's connection opens (a step may create the very database + // the connection targets). Each runs like a global step: own connection, committed work. + bool setupOk = true; + foreach (var name in header.Setup) + { + if (!_opt.Steps.TryGetValue(name, out var st)) + { + result.Assertions.Add(new AssertionResult + { + Name = $"-- @setup {name}", + Outcome = Outcome.Error, + Message = $"unknown step '{name}' — define it under TestRunner:Steps", + }); + setupOk = false; + break; + } + try + { + _log?.LogDebug("{File}: @setup {Step}", contextName, name); + await RunStepAsync(st, "setup", ct); + } + catch (Exception ex) + { + result.Assertions.Add(new AssertionResult { Name = $"-- @setup {name}", Outcome = Outcome.Error, Message = ex.Message }); + setupOk = false; + break; + } + } + + if (setupOk) + try + { + conn = new NpgsqlConnection(connString); conn.Notice += (_, e) => { // Route notices through the test runner's own log channel, tagged with this test file. The @@ -224,7 +294,10 @@ private async Task RunFileAsync(string file, IReadOnlyDictionary s is HttpStep); int httpOrdinal = 0; @@ -267,6 +340,7 @@ private async Task RunFileAsync(string file, IReadOnlyDictionary RunFileAsync(string file, IReadOnlyDictionary results) if (_opt.DetailedReport) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); continue; } - var loc = a.Line is null ? "" : $" [{rel}:{a.Line}]"; + var locFile = a.SourceFile is null ? rel : Path.GetRelativePath(Environment.CurrentDirectory, a.SourceFile); + var loc = a.Line is null ? "" : $" [{locFile}:{a.Line}]"; _out.LineAnsi($" ✗ {a.Name}{loc}", AnsiFail); if (!string.IsNullOrWhiteSpace(a.Message) && a.Message != a.Name) _out.LineAnsi($" {a.Message}", AnsiFail); WriteFailingSql(a.Sql); @@ -689,9 +804,9 @@ private static void WriteJUnit(List results, string path) new XAttribute("classname", fr.File), new XAttribute("time", caseTimeStr)); if (a.Outcome == Outcome.Fail) - tc.Add(new XElement("failure", new XAttribute("message", a.Message ?? "assertion failed"), $"{fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); + tc.Add(new XElement("failure", new XAttribute("message", a.Message ?? "assertion failed"), $"{a.SourceFile ?? fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); else if (a.Outcome == Outcome.Error) - tc.Add(new XElement("error", new XAttribute("message", a.Message ?? "error"), $"{fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); + tc.Add(new XElement("error", new XAttribute("message", a.Message ?? "error"), $"{a.SourceFile ?? fr.File}{(a.Line is null ? "" : $":{a.Line}")}")); if (a.Outcome != Outcome.Pass && fr.Notices.Count > 0) tc.Add(new XElement("system-out", string.Join('\n', fr.Notices))); suite.Add(tc); diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 53b47018..d61133e9 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -50,10 +50,16 @@ public class TestRunnerOptions public ResponseTempTableOptions ResponseTempTable { get; set; } = new(); - /// Run-once setup, before endpoint discovery. Commands run first, then SqlFile/Sql (declared order within each group). + /// + /// Named, reusable steps (name → step). Referenced by name from / + /// or from an individual test file header (-- @setup Name / -- @teardown Name). + /// + public Dictionary Steps { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// Run-once setup, before endpoint discovery, in the exact order written. public List Setup { get; set; } = []; - /// Run-once teardown, always (best-effort). Reverse: SqlFile/Sql first, then Commands. + /// Run-once teardown, always (best-effort), in the exact order written. public List Teardown { get; set; } = []; } @@ -81,6 +87,9 @@ public class ResponseColumnOptions /// One Setup/Teardown step — exactly one of Sql / SqlFile / Command. public class TestSetupStep { + /// Registry name when the step is defined under Steps; null for inline steps. Used in logs. + public string? Name { get; set; } + /// Inline SQL executed as a single batch. public string? Sql { get; set; } diff --git a/NpgsqlRestClient/Testing/TestStep.cs b/NpgsqlRestClient/Testing/TestStep.cs index 25434d80..6d729b68 100644 --- a/NpgsqlRestClient/Testing/TestStep.cs +++ b/NpgsqlRestClient/Testing/TestStep.cs @@ -7,6 +7,8 @@ public enum TestStepKind Sql, /// An embedded HTTP request (a /* ... */ block whose first line is a request line). Http, + /// An include line (\i file or \ir file) — expanded into the included file's steps. + Include, } /// One ordered step in a parsed .test.sql file. @@ -15,6 +17,11 @@ public abstract class TestStep public TestStepKind Kind { get; } /// 1-based line in the source file where this step starts (for error reporting). public int LineNumber { get; } + /// + /// Full path of the file this step came from when it was spliced in via \i/\ir; + /// null when the step belongs to the file being executed itself. + /// + public string? SourceFile { get; set; } protected TestStep(TestStepKind kind, int lineNumber) { @@ -23,6 +30,24 @@ protected TestStep(TestStepKind kind, int lineNumber) } } +/// +/// An include line: \i path (path relative to the current working directory, like every other +/// configured path) or \ir path (relative to the file containing the include) — psql semantics. +/// Expanded by into the included file's steps, as if the content were pasted. +/// +public sealed class IncludeStep : TestStep +{ + public string Path { get; } + /// True for \ir (relative to the including file), false for \i (cwd-relative). + public bool RelativeToFile { get; } + + public IncludeStep(string path, bool relativeToFile, int lineNumber) : base(TestStepKind.Include, lineNumber) + { + Path = path; + RelativeToFile = relativeToFile; + } +} + /// A SQL statement step — executed via ExecuteReader; a boolean first column is an assertion. public sealed class SqlStep : TestStep { diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index f61922b2..e6e83f68 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1444,6 +1444,7 @@ // point at a dedicated test database that a Setup step creates first (it need not exist at startup). // Empty = use the main connection. Tip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), // stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). + // Need several distinct tokens of the same length? Indexed instances {rndN_1}..{rndN_9} are each independent. // "ConnectionName": "", // @@ -1500,7 +1501,19 @@ } }, // - // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is one of: + // Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in + // Setup/Teardown below, or from an individual test file's leading header comments: + // -- @setup StepName [StepName ...] runs before that file + // -- @teardown StepName [StepName ...] runs after that file (always, best-effort) + // -- @connection Name runs that file on a named ConnectionStrings entry + // Annotations are repeatable; names may be whitespace- or comma-separated and run in the order written. + // Test files can also reuse SQL scripts in place with psql-style includes: `\i file` (cwd-relative) or + // `\ir file` (relative to the including file) — executed on the test's connection, inside its transaction. + // + "Steps": {}, + // + // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is a step + // NAME from "Steps" above, or an inline step object: // { "Command": "...", "WorkingDirectory": "..." } — runs via the OS shell // { "Sql": "..." } | { "SqlFile": "..." } — runs on the test connection; set "ConnectionName" // to run on another ConnectionStrings entry (e.g. @@ -1508,7 +1521,7 @@ // "Setup": [], // - // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same step shapes as Setup. + // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same entries as Setup. // "Teardown": [] }, diff --git a/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs b/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs new file mode 100644 index 00000000..9be06a46 --- /dev/null +++ b/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs @@ -0,0 +1,196 @@ +using NpgsqlRestClient.Testing; + +namespace NpgsqlRestTests.TestRunnerTests.ParserTests; + +public class TestFileHeaderTests +{ + [Fact] + public void Header_Parses_Setup_Teardown_And_Connection() + { + var h = TestFileHeader.Parse("-- @setup CreateDb ApplyMigrations\n-- @teardown DropDb\n-- @connection Isolated\n\nselect 1;"); + h.Setup.Should().Equal("CreateDb", "ApplyMigrations"); + h.Teardown.Should().Equal("DropDb"); + h.ConnectionName.Should().Be("Isolated"); + } + + [Fact] + public void Header_Annotations_Are_Repeatable_And_Case_Insensitive() + { + var h = TestFileHeader.Parse("-- @SETUP A\n-- @Setup B\nselect 1;"); + h.Setup.Should().Equal("A", "B"); + } + + [Fact] + public void Names_May_Be_Separated_By_Whitespace_Or_Commas() + { + var h = TestFileHeader.Parse("-- @setup A, B\n-- @setup C,D E\n-- @teardown X , Y\nselect 1;"); + h.Setup.Should().Equal("A", "B", "C", "D", "E"); + h.Teardown.Should().Equal("X", "Y"); + } + + [Fact] + public void Header_Scan_Stops_At_First_Statement() + { + var h = TestFileHeader.Parse("-- @setup A\nselect 1;\n-- @setup B"); + h.Setup.Should().Equal("A"); + } + + [Fact] + public void Plain_Comments_And_Blank_Lines_Do_Not_End_The_Header() + { + var h = TestFileHeader.Parse("-- Test: something.\n\n-- more docs\n-- @teardown Drop\nselect 1;"); + h.Teardown.Should().Equal("Drop"); + } + + [Fact] + public void Unknown_Annotations_And_Bare_Keywords_Are_Ignored() + { + var h = TestFileHeader.Parse("-- @author me\n-- @setup\n-- @setup X\nselect 1;"); + h.Setup.Should().Equal("X"); + h.Teardown.Should().BeEmpty(); + h.ConnectionName.Should().BeNull(); + } +} + +public class IncludeParseTests +{ + [Fact] + public void Include_Lines_Are_Recognized_Between_Statements() + { + var steps = SqlTestFileParser.Parse("begin;\n\\ir fixtures/users.sql\n\\i ./global/seed.sql\nselect 1;\nrollback;"); + steps.Should().HaveCount(5); + steps[1].Should().BeOfType().Which.RelativeToFile.Should().BeTrue(); + ((IncludeStep)steps[1]).Path.Should().Be("fixtures/users.sql"); + steps[2].Should().BeOfType().Which.RelativeToFile.Should().BeFalse(); + ((IncludeStep)steps[2]).Path.Should().Be("./global/seed.sql"); + } + + [Fact] + public void Quoted_Path_And_Trailing_Semicolon_Are_Handled() + { + var steps = SqlTestFileParser.Parse("\\ir 'my fixtures/data.sql';\n"); + steps.Should().ContainSingle().Which.Should().BeOfType() + .Which.Path.Should().Be("my fixtures/data.sql"); + } + + [Fact] + public void Backslash_Inside_A_Statement_Or_String_Is_Not_An_Include() + { + var steps = SqlTestFileParser.Parse("select '\\ir not-an-include.sql';"); + steps.Should().ContainSingle().Which.Should().BeOfType() + .Which.Text.Should().Contain("\\ir not-an-include.sql"); + } + + [Fact] + public void Unrecognized_Backslash_Line_Stays_Sql_Text() + { + var steps = SqlTestFileParser.Parse("\\echo hello;\nselect 1;"); + steps.Should().HaveCount(2); + steps[0].Should().BeOfType().Which.Text.Should().StartWith("\\echo"); + } +} + +public class IncludeExpansionTests : IDisposable +{ + private readonly string _dir = Directory.CreateTempSubdirectory("npgsqlrest-include-tests").FullName; + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private string Write(string name, string content) + { + var path = Path.Combine(_dir, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void Included_Statements_Are_Spliced_In_Place_With_Source_Attribution() + { + var inc = Write("fixtures/users.sql", "insert into t values (1);\ninsert into t values (2);"); + var main = Write("main.test.sql", "begin;\n\\ir fixtures/users.sql\nselect true, 'ok';\nrollback;"); + + var steps = TestFileLoader.LoadSteps(File.ReadAllText(main), main); + + steps.Should().HaveCount(5); + steps[0].SourceFile.Should().BeNull(); // begin (main file) + steps[1].SourceFile.Should().Be(inc); // spliced insert 1 + steps[2].SourceFile.Should().Be(inc); // spliced insert 2 + ((SqlStep)steps[1]).Text.Should().Contain("values (1)"); + steps[3].SourceFile.Should().BeNull(); // the assertion (main file) + } + + [Fact] + public void Nested_Includes_Resolve_Relative_To_The_Including_File() + { + Write("a/inner.sql", "select 'inner';"); + Write("a/outer.sql", "\\ir inner.sql"); + var main = Write("main.test.sql", "\\ir a/outer.sql"); + + var steps = TestFileLoader.LoadSteps(File.ReadAllText(main), main); + + steps.Should().ContainSingle().Which.Should().BeOfType() + .Which.Text.Should().Contain("inner"); + } + + [Fact] + public void Circular_Include_Is_Reported() + { + Write("x.sql", "\\ir y.sql"); + Write("y.sql", "\\ir x.sql"); + var main = Write("main.test.sql", "\\ir x.sql"); + + var act = () => TestFileLoader.LoadSteps(File.ReadAllText(main), main); + act.Should().Throw().WithMessage("*circular include*"); + } + + [Fact] + public void Missing_Include_Is_Reported_With_Path_And_Line() + { + var main = Write("main.test.sql", "select 1;\n\\ir nope.sql"); + var act = () => TestFileLoader.LoadSteps(File.ReadAllText(main), main); + act.Should().Throw().WithMessage("*include not found*nope.sql*"); + } + + [Fact] + public void Http_Blocks_In_Included_Files_Are_Spliced_Like_Everything_Else() + { + var inc = Write("shared/login_call.sql", "/*\nPOST /api/login\n# @response shared_login\n\n{\"email\": \"x\"}\n*/"); + var main = Write("main.test.sql", "begin;\n\\ir shared/login_call.sql\nselect true, 'ok';\nrollback;"); + + var steps = TestFileLoader.LoadSteps(File.ReadAllText(main), main); + + steps.Should().HaveCount(4); + var http = steps[1].Should().BeOfType().Subject; + http.SourceFile.Should().Be(inc); + http.Method.Should().Be("POST"); + http.ResponseTable.Should().Be("shared_login"); + } + + [Fact] + public void Header_Annotations_In_Included_Files_Count_As_If_Pasted() + { + // A shared "profile": annotations only — the header continues in the host after it. + Write("shared/iso_profile.sql", "-- shared isolation profile\n-- @setup CreateIso\n-- @teardown DropIso\n-- @connection Isolated\n"); + var main = Write("main.test.sql", "-- docs\n\\ir shared/iso_profile.sql\n-- @setup HostStep\nselect 1;"); + + var h = TestFileHeader.Parse(File.ReadAllText(main), main); + + h.Setup.Should().Equal("CreateIso", "HostStep"); + h.Teardown.Should().Equal("DropIso"); + h.ConnectionName.Should().Be("Isolated"); + } + + [Fact] + public void An_Include_Containing_Statements_Ends_The_Header_Like_A_Paste_Would() + { + // The fixture's own leading annotation is in the pasted header region (counts); the host + // annotation AFTER the include is behind the fixture's first statement (does not count). + Write("fixture.sql", "-- @setup FromFixture\ninsert into t values (1);"); + var main = Write("main.test.sql", "\\ir fixture.sql\n-- @setup TooLate\nselect 1;"); + + var h = TestFileHeader.Parse(File.ReadAllText(main), main); + + h.Setup.Should().Equal("FromFixture"); + } +} diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 36ec163c..fefdf6c5 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -123,16 +123,75 @@ select (select body::jsonb ->> 'email' from _response) = 'grace@example.com', 'email is normalized to lowercase'; ``` -### Setup and Teardown +### Reusing scripts: `\i` and `\ir` includes -Run-once steps around the whole test session. **`Setup` runs before endpoint discovery** (so it can create/migrate the very schema the endpoints are built from); **`Teardown` always runs** at the end — even when tests fail or Setup itself fails (best-effort; `Keep: true` skips it to let you inspect state). Steps execute in the **exact order written**; each entry is one of: +Shared SQL — fixture inserts, utility scripts — can be spliced into any test with psql's include syntax on its own line: + +```sql +begin; + +\ir fixtures/extra_users.sql -- path relative to THIS file (psql \ir semantics) +\i ./shared/reset_counters.sql -- path relative to the cwd, like every other configured path + +/* GET /api/get-users */ +select (select jsonb_array_length(body::jsonb) from _response) = 5, 'seed + fixture users listed'; + +rollback; +``` + +An include behaves **as if you pasted the file's content at that spot**: SQL statements, assertions (counted as tests, attributed to the included file), **HTTP blocks** (they participate in `_response_{n}` numbering exactly as if pasted), and even **header annotations** — `-- @setup`/`-- @teardown`/`-- @connection` in an included file's leading comments count when the include sits in the host file's header region. That last one enables a **shared profile** idiom: put an annotation set in one file and attach it with a single include line (see example 21's `shared/isolated_database.sql` — one `\ir` line gives a test its own cloned database). Everything runs **on the test's connection, inside the test's transaction** — a fixture included this way rolls back with the test, so it is invisible to every other test and leaves no residue. + +Two footnotes where "pasted" is refined rather than literal: + +- An include must stand **between complete statements** — it cannot sit inside an unfinished statement or contribute a *fragment* of one (for reusable SQL fragments, use what PostgreSQL already provides: functions and views). +- Error attribution is **better than a paste**: a failure inside an included file is reported with the *included file's* name and line (the same thing psql does), not a line number in an imaginary merged file. + +Includes nest (cycle-safe, depth-capped) and work in `Setup`/`Teardown` `SqlFile` steps too. A path may be single-quoted (`\ir 'my fixtures/data.sql'`) and a trailing `;` is forgiven; a non-include backslash line is passed through to PostgreSQL untouched (no other psql meta-commands are supported). + +### Setup and Teardown, and named steps + +Run-once steps around the whole test session. **`Setup` runs before endpoint discovery** (so it can create/migrate the very schema the endpoints are built from); **`Teardown` always runs** at the end — even when tests fail or Setup itself fails (best-effort; `Keep: true` skips it to let you inspect state). Steps execute in the **exact order written**; a step is one of: - `{ "Sql": "..." }` — inline SQL, -- `{ "SqlFile": "path" }` — a SQL file (executed statement by statement, like test files), +- `{ "SqlFile": "path" }` — a SQL file (executed statement by statement, like test files; `\i`/`\ir` includes work), - `{ "Command": "...", "WorkingDirectory": "..." }` — a shell command (e.g. `docker compose up -d`, an external migration tool). Non-zero exit fails Setup. `Sql`/`SqlFile` steps run on the test connection by default, or on any named `ConnectionStrings` entry via a per-step `"ConnectionName"` — which enables maintenance operations like `create database` without the runner ever issuing DDL on its own. +Steps can be defined once in the **`Steps` registry** (name → step, like `ConnectionStrings` or `CacheOptions.Profiles`) and referenced by name; `Setup`/`Teardown` arrays accept names and inline objects mixed. Referencing an unknown name is a configuration error (exit 3). + +```jsonc +"Steps": { + "CreateDatabase": { "Sql": "create database app_test_{rnd5}", "ConnectionName": "Admin" }, + "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, + "DropDatabase": { "Sql": "drop database if exists app_test_{rnd5} with (force)", "ConnectionName": "Admin" } +}, +"Setup": ["CreateDatabase", "ApplyMigrations"], +"Teardown": ["DropDatabase"] +``` + +### Per-file setup, teardown, and connection (header annotations) + +An individual test file can attach named steps — and pick its own connection — with leading `--` comment annotations (the same annotation idiom endpoint `.sql` files use), placed before the first statement: + +```sql +-- @setup CreateIsolatedDb +-- @teardown DropIsolatedDb +-- @connection Isolated +``` + +- `-- @setup Name [Name …]` — runs the named steps **before this file** (own connections, committed work — e.g. clone a database this file will use). An unknown step name fails the file with an error. +- `-- @teardown Name [Name …]` — runs **after this file, always** (best-effort, even when the test fails or times out), after the file's connection is closed — so a `drop database … with (force)` teardown works. An unknown step name logs a warning. +- `-- @connection Name` — runs this file, **including its in-process endpoint calls**, on a named `ConnectionStrings` entry instead of the test connection. An unknown name fails the file with an error. + +`@setup` and `@teardown` are **repeatable**, and one line may carry **several names, separated by whitespace or commas** (the NpgsqlRest annotation idiom — `-- @setup A B`, `-- @setup A, B`, and two `-- @setup` lines are all equivalent). Names accumulate and execute in **exactly the order written** — the same contract as the global `Setup`/`Teardown` arrays; teardown is *not* reversed, so write the step you want last, last. Setup is fail-fast (the first failing or unknown step stops the chain, the file body never runs, teardown still runs); each teardown step is best-effort (a failure logs a warning and the remaining steps still run). + +Annotations can also come from an **include in the header region** — includes behave as if pasted, so a shared annotation "profile" file attaches with one line: `\ir shared/isolated_database.sql` (see the includes section above). + +Since every word after `@setup`/`@teardown` is read as a step name, don't *describe* these annotations in a file's header comments using their literal syntax (`-- @setup CreateDb creates the db…` would try to run steps named `creates`, `the`, `db…`). Other `--` comment lines in the header are ignored as usual. + +Together these give **per-test database isolation**: a file's setup clones a migrated template (`create database … template …` — a near-instant file-level copy), `@connection` points the file at the clone, and teardown drops it. That is the escalation path for state that transaction rollback cannot isolate — most prominently **sequences**, which advance even when the transaction rolls back, so generated ids are only deterministic in a fresh clone. Per-file steps commit to shared state, so steps that mutate the *shared* test database should be idempotent or run under `MaxParallelism: 1`; in-transaction fixture reuse belongs to `\ir` instead. + ### A dedicated test database `TestRunner.ConnectionName` points the whole test session — endpoint type-checking (SQL-file Describe), endpoint execution, and the tests — at a named `ConnectionStrings` entry instead of the app's main connection. That database **need not exist at startup**: it is never opened before Setup, so the first Setup step can create it. @@ -146,19 +205,19 @@ Run-once steps around the whole test session. **`Setup` runs before endpoint dis "TestRunner": { "ConnectionName": "Test", "FilePattern": "./tests/**/*.test.sql", - "Setup": [ - { "Sql": "create database app_test_{rnd6}", "ConnectionName": "Admin" }, - { "SqlFile": "./migrations/schema.sql" } // runs on "Test" - ], - "Teardown": [ - { "Sql": "drop database app_test_{rnd6} with (force)", "ConnectionName": "Admin" } - ] + "Steps": { + "CreateDatabase": { "Sql": "create database app_test_{rnd6}", "ConnectionName": "Admin" }, + "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, // runs on "Test" + "DropDatabase": { "Sql": "drop database if exists app_test_{rnd6} with (force)", "ConnectionName": "Admin" } + }, + "Setup": ["CreateDatabase", "ApplyMigrations"], + "Teardown": ["DropDatabase"] } ``` -**`{rnd1}`…`{rnd10}`** are random lowercase tokens (length = the digit), generated once per run and substituted everywhere `{ENV}` placeholders work — connection strings, Setup/Teardown SQL, and Commands. The same token yields the same value across the whole config, so the connection string, the `create`, and the `drop` all name the same database; concurrent suites on a shared server can't collide. (Trade-off: a hard crash that skips Teardown orphans that run's database. A static name with a leading `drop database if exists … with (force);` in Setup is the self-healing alternative.) +**`{rnd1}`…`{rnd10}`** are random lowercase tokens (length = the digit), generated once per run and substituted everywhere `{ENV}` placeholders work — connection strings, Setup/Teardown SQL, and Commands. The same token yields the same value across the whole config, so the connection string, the `create`, and the `drop` all name the same database; concurrent suites on a shared server can't collide. When several **distinct** tokens of the same length are needed, the indexed instances **`{rndN_1}`…`{rndN_9}`** are each independent — `{rnd3}`, `{rnd3_1}` and `{rnd3_2}` are three different 3-character tokens, each stable for the run. (Trade-off: a hard crash that skips Teardown orphans that run's database. A static name with a leading `drop database if exists … with (force);` in Setup is the self-healing alternative.) -The same Setup/Teardown machinery covers the neighboring workflows with no additional features: clone a prepared **template** (`create database … template app_template` — near-instant), start a **Docker** Postgres (`Command` steps: `docker run` → wait for `pg_isready` → migrate; `docker rm -f` in Teardown), or run an **external migrator** (EF Core, Django, Flyway) as a `Command`. +The same Setup/Teardown machinery covers the neighboring workflows with no additional features: clone a prepared **template** (`create database … template app_template` — near-instant; migrate the template once in Setup and clone it for the run *and* for `-- @setup`-annotated per-test databases), start a **Docker** Postgres (`Command` steps: `docker run` → wait for `pg_isready` → migrate; `docker rm -f` in Teardown), or run an **external migrator** (EF Core, Django, Flyway) as a `Command`. ### Reporting @@ -214,8 +273,9 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "Headers": "headers", "IsSuccess": "is_success" } }, - "Setup": [], // run-once, BEFORE endpoint discovery, in written order - "Teardown": [] // run-once, ALWAYS, in written order (Keep skips) + "Steps": {}, // named, reusable steps (name → step) for Setup/Teardown and -- @setup/-- @teardown + "Setup": [], // run-once, BEFORE endpoint discovery, in written order (step names or inline objects) + "Teardown": [] // run-once, ALWAYS, in written order (Keep skips; same entries as Setup) } ``` @@ -262,8 +322,8 @@ Each named logger is controlled independently — e.g. mute the application logg - The test runner's core hooks are additive and inert outside `--test`: an ambient-connection accessor on the endpoint pipeline (null by default) and response headers on the internal invocation result. Normal server operation is unchanged. - Test files run **statement by statement** (the client operates Npgsql with SQL rewriting disabled — one statement per command), which is also `psql`'s default execution model; explicit `begin`/`commit`/`rollback` in a file work as ordinary statements. `Setup`/`Teardown` `Sql`/`SqlFile` steps execute the same way — which is why `create database` works as a plain step. - All new options are wired through the client configuration: `appsettings.json`, the JSON-schema descriptions, and the `--config` template. -- Working examples: [`examples/19_testing_basic`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/19_testing_basic) (co-located layout, multi-step scenario files) and [`examples/20_testing_newdb`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/20_testing_newdb) (separate `tests/` tree, one test per file, fresh test database per run, deferrable-constraint fixtures, authorization + user parameters). +- Working examples: [`examples/19_testing_basic`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/19_testing_basic) (co-located layout, multi-step scenario files), [`examples/20_testing_newdb`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/20_testing_newdb) (separate `tests/` tree, one test per file, fresh test database per run via named steps, deferrable-constraint fixtures, authorization + user parameters), and [`examples/21_testing_isolation`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/21_testing_isolation) (template-clone workflow; two parallel per-test isolated databases — named apart with the indexed `{rnd5_1}`/`{rnd5_2}` tokens — proving deterministic sequence ids; a shared annotation profile attached via `\ir`; a `Command` step mixed with named-step references in Setup). ## Tests -Full test suite green (2334), including 33 parser unit tests for the test-file and HTTP-block parsers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). Both documentation examples verified end-to-end against live PostgreSQL (19 assertions in 9 files for example 20, including create-database-per-run with a `{rnd}` name and teardown drop). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). +Full test suite green (2351), including 50 parser unit tests for the test-file, HTTP-block, header-annotation, and include parsers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). From bd4fed55a6019e69750645abd583591402516c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 18:34:42 +0200 Subject: [PATCH 04/14] feat(test-runner): filtering, watch mode, tags, endpoint coverage (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test filtering (TestRunner.Filter): - Narrow the discovered set: a value without wildcards is a case-insensitive substring match against the cwd-relative path (--testrunner:filter=login); with wildcards it uses the same glob engine as FilePattern. Setup/Teardown still run; zero matches exits 4 (AllowEmpty applies). Watch mode (--watch / TestRunner.Watch, interactive/dev-only): - Setup once, run once, then watch the FilePattern base directory recursively for *.sql changes (debounced): a changed test file re-runs alone (Filter applies); any other changed .sql (an included fixture/profile) re-runs everything. Endpoint sources are not watched — restart to rebuild. - Teardown runs ONCE, on exit: SIGINT and SIGTERM are intercepted via PosixSignalRegistration (Console.CancelKeyPress proved unreliable headless; plain SIGTERM previously killed the process without teardown, leaking the test database). Graceful stop exits 0 — not for CI gating. - Extracted ExecuteDiscoveredFilesAsync (run core without teardown); RunAsync keeps teardown-in-finally for CI mode. Tags (-- @tag name [name ...] header annotation): - TestRunner.Tag runs only files carrying at least one listed tag; TestRunner.ExcludeTag skips files carrying any (exclude wins). CSV or whitespace-separated, case-insensitive, composes with Filter. Tags travel through profile includes (as-if-pasted), so e.g. every clone-isolated test is automatically tagged slow by its profile. Endpoint coverage (TestRunner.Coverage / CoverageThreshold): - After the run: exercised N of M testable endpoints + the list of untested ones (kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded from the ratio and counted separately). CoverageThreshold (0-100, implies Coverage) fails an otherwise-passing run with exit 2 — CI gating for "every endpoint has a test". Covered = invoked at least once. Config synced across appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs; --watch whitelisted; --help updated. Changelog v3.19.0.md updated with all four features. Full suite green (2364) incl. 63 runner unit tests; examples 19/20/21 verified end-to-end (filter modes, watch rerun-on-change + signal teardown, tag taxonomies incl. profile-carried tags, coverage report + failing threshold gate). --- NpgsqlRestClient/Builder.cs | 12 + NpgsqlRestClient/Config.cs | 2 +- NpgsqlRestClient/ConfigDefaults.cs | 6 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 4 + NpgsqlRestClient/ConfigTemplate.cs | 30 ++ NpgsqlRestClient/Program.cs | 14 +- NpgsqlRestClient/Testing/TestFileLoader.cs | 7 + NpgsqlRestClient/Testing/TestRunner.cs | 330 ++++++++++++++++-- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 38 ++ NpgsqlRestClient/appsettings.json | 30 ++ .../TestFileHeaderAndIncludeTests.cs | 40 +++ changelog/v3.19.0.md | 50 ++- 12 files changed, 531 insertions(+), 32 deletions(-) diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index f6be212f..74b4c395 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3068,6 +3068,9 @@ public TestRunnerOptions BuildTestRunnerOptions() if (section.Exists() is false) return opt; opt.FilePattern = _config.GetConfigStr("FilePattern", section) ?? ""; + opt.Filter = _config.GetConfigStr("Filter", section); + opt.Tags = SplitNames(_config.GetConfigStr("Tag", section)); + opt.ExcludeTags = SplitNames(_config.GetConfigStr("ExcludeTag", section)); opt.ConnectionName = _config.GetConfigStr("ConnectionName", section); opt.MaxParallelism = _config.GetConfigInt("MaxParallelism", section) ?? 0; opt.FailFast = _config.GetConfigBool("FailFast", section, false); @@ -3076,6 +3079,9 @@ public TestRunnerOptions BuildTestRunnerOptions() opt.Keep = _config.GetConfigBool("Keep", section, false); opt.DetailedReport = _config.GetConfigBool("DetailedReport", section, false); opt.AllowEmpty = _config.GetConfigBool("AllowEmpty", section, false); + opt.Watch = _config.GetConfigBool("Watch", section, false); + opt.CoverageThreshold = _config.GetConfigInt("CoverageThreshold", section); + opt.Coverage = _config.GetConfigBool("Coverage", section, false) || opt.CoverageThreshold is not null; var rt = section.GetSection("ResponseTempTable"); if (rt.Exists()) @@ -3110,6 +3116,12 @@ public TestRunnerOptions BuildTestRunnerOptions() opt.Teardown = ReadTestSteps(section.GetSection("Teardown")); return opt; + // "a, b" or "a b" → ["a","b"] (same separators as the header annotations). + static List SplitNames(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : [.. value.Split([',', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + // Absent key => keep default name; explicit empty string => omit the column. string? ReadColumnName(IConfigurationSection cols, string key, string? def) { diff --git a/NpgsqlRestClient/Config.cs b/NpgsqlRestClient/Config.cs index ba1cf919..4efa46b5 100644 --- a/NpgsqlRestClient/Config.cs +++ b/NpgsqlRestClient/Config.cs @@ -1145,7 +1145,7 @@ private static void LoadEnvFile(string path) { commandLineArgs.Add(arg); } - else if (lower is "--json" or "--validate" or "--endpoints" or "--test") + else if (lower is "--json" or "--validate" or "--endpoints" or "--test" or "--watch") { // Known flags handled by Program.cs, skip silently } diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index f6e6a7f6..57439532 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1101,6 +1101,9 @@ private static JsonObject GetTestRunnerDefaults() return new JsonObject { ["FilePattern"] = "", + ["Filter"] = "", + ["Tag"] = "", + ["ExcludeTag"] = "", ["ConnectionName"] = "", ["MaxParallelism"] = 0, ["FailFast"] = false, @@ -1109,6 +1112,9 @@ private static JsonObject GetTestRunnerDefaults() ["Keep"] = false, ["DetailedReport"] = false, ["AllowEmpty"] = false, + ["Watch"] = false, + ["Coverage"] = false, + ["CoverageThreshold"] = null, ["LoggerName"] = "NpgsqlRestTest", ["ResponseTempTable"] = new JsonObject { diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 01f0031f..895d01e5 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -248,6 +248,9 @@ public static partial class ConfigSchemaGenerator ["Stats:ActivityPath"] = "Path for current database activity.\nReturns data from pg_stat_activity showing active sessions, queries, and wait events.\nSecurity Consideration: Shows currently running queries which may contain sensitive data.", ["TestRunner"] = "SQL test runner. Invoked with the `--test` command-line flag (otherwise inert).\nDiscovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints in-process from an embedded `/* GET /path */` block (response captured into a temp table), and asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks.\nExit codes: 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found.", ["TestRunner:FilePattern"] = "Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables discovery.\nCo-located convention: app.sql (endpoint) next to app.test.sql (test).", + ["TestRunner:Filter"] = "Optional filter narrowing the discovered set — the fast path for iterating on one test: npgsqlrest ... --test --testrunner:filter=login\nMatched against each file's cwd-relative path: a value without wildcards is a substring match; with wildcards it is the same glob engine as FilePattern. Empty = run everything discovered.", + ["TestRunner:Tag"] = "Run only test files carrying at least one of these tags (comma- or whitespace-separated; case-insensitive). A file declares tags with a `-- @tag name [name ...]` header annotation. Composes with ExcludeTag and Filter.", + ["TestRunner:ExcludeTag"] = "Skip test files carrying any of these tags (comma- or whitespace-separated; case-insensitive; exclude wins over Tag).", ["TestRunner:ConnectionName"] = "Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection.\nIn test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can point at a dedicated test database that a Setup step creates first (it need not exist at startup). Empty = use the main connection.\nTip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). Need several distinct tokens of the same length? Indexed instances {rndN_1}..{rndN_9} are each independent.", ["TestRunner:MaxParallelism"] = "Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection.", ["TestRunner:FailFast"] = "Stop scheduling new tests after the first failure/error (in-flight tests still finish).", @@ -256,6 +259,7 @@ public static partial class ConfigSchemaGenerator ["TestRunner:Keep"] = "Skip Teardown so a failed run's state can be inspected.", ["TestRunner:DetailedReport"] = "Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show captured `raise notice` output for passing tests too.\nThis shapes the report only — for diagnostic logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName).", ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", + ["TestRunner:Watch"] = "Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file changes until Ctrl+C.\nA changed test file re-runs alone; any other changed .sql under the watched tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating).", ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index f0fe916f..7382ddcc 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1448,6 +1448,21 @@ public static partial class ConfigSchemaGenerator // "FilePattern": "", // + // Optional filter narrowing the discovered set — the fast path for iterating on one test: + // npgsqlrest ... --test --testrunner:filter=login + // Matched against each file's cwd-relative path: a value without wildcards is a substring match; + // with wildcards it is the same glob engine as FilePattern. Empty = run everything discovered. + // + "Filter": "", + // + // Tag filtering (comma- or whitespace-separated lists; case-insensitive). A test file declares tags + // with a `-- @tag name [name ...]` header annotation. "Tag" runs only files carrying at least one of + // the listed tags; "ExcludeTag" skips files carrying any of them (exclude wins). Composes with Filter. + // npgsqlrest ... --test --testrunner:tag=smoke --testrunner:excludetag=slow + // + "Tag": "", + "ExcludeTag": "", + // // Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection. In // test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can // point at a dedicated test database that a Setup step creates first (it need not exist at startup). @@ -1487,6 +1502,21 @@ public static partial class ConfigSchemaGenerator // "AllowEmpty": false, // + // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file + // changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched + // tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to + // rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating). + // + "Watch": false, + // + // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested + // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded + // from the ratio and counted separately). CoverageThreshold (0-100, implies Coverage) fails an + // otherwise-passing run with exit 2 when coverage is below it — CI gating for "every endpoint has a test". + // + "Coverage": false, + "CoverageThreshold": null, + // // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. // Discovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. // diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 090a2e22..c17764f9 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -39,6 +39,7 @@ ("npgsqlrest --annotations", "Output all supported comment annotations as JSON. Syntax highlighted in terminal, plain JSON when piped."), ("npgsqlrest [files...] --endpoints", "Connect to database, discover endpoints, output as JSON, then exit. Syntax highlighted in terminal, plain JSON when piped."), ("npgsqlrest [files...] --test", "Run SQL test files (TestRunner config), then exit. Exit codes: 0 pass, 1 failures, 2 errors, 3 config error, 4 no tests."), + ("npgsqlrest [files...] --test --watch", "Watch mode: run the tests, then re-run on file changes until Ctrl+C (interactive/dev-only; teardown runs on exit)."), (" ", " "), ("npgsqlrest --hash [value]", "Hash value with default hasher and print to console."), ("npgsqlrest --basic_auth [username] [password]", "Print out basic basic auth header value in format 'Authorization: Basic base64(username:password)'."), @@ -487,6 +488,7 @@ // SQL test runner: run Setup (Commands then SqlFile/Sql) BEFORE discovery so migrations can build the // schema that UseNpgsqlRest then discovers. The runner sets options.AmbientConnectionAccessor in its ctor. NpgsqlRestClient.Testing.TestRunner? testRunner = null; +NpgsqlRestClient.Testing.TestRunnerOptions? testRunnerOptions = null; if (testMode) { // Test-mode invariants: the endpoint must never begin/commit on the test's connection, and caching @@ -502,7 +504,8 @@ try { - testRunner = new NpgsqlRestClient.Testing.TestRunner(options, builder.BuildTestRunnerOptions(), connectionString, builder.TestLogger, logTestNotices, builder.BuildTestRunnerConnectionStrings()); + testRunnerOptions = builder.BuildTestRunnerOptions(); + testRunner = new NpgsqlRestClient.Testing.TestRunner(options, testRunnerOptions, connectionString, builder.TestLogger, logTestNotices, builder.BuildTestRunnerConnectionStrings()); } catch (Exception ex) { @@ -534,8 +537,13 @@ if (testMode) { - // Endpoints are now built; run the test files (then Teardown), and exit. - Environment.ExitCode = await testRunner!.RunAsync(EndpointCapture.Endpoints); + // Endpoints are now built; run the test files (then Teardown), and exit. `--watch` (or + // TestRunner:Watch) keeps the process alive and re-runs on file changes until Ctrl+C. + bool watchMode = args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) + || testRunnerOptions!.Watch; + Environment.ExitCode = watchMode + ? await testRunner!.RunWatchAsync(EndpointCapture.Endpoints) + : await testRunner!.RunAsync(EndpointCapture.Endpoints); return; } diff --git a/NpgsqlRestClient/Testing/TestFileLoader.cs b/NpgsqlRestClient/Testing/TestFileLoader.cs index 310bc45f..bb2e0b54 100644 --- a/NpgsqlRestClient/Testing/TestFileLoader.cs +++ b/NpgsqlRestClient/Testing/TestFileLoader.cs @@ -7,6 +7,7 @@ namespace NpgsqlRestClient.Testing; /// -- @setup StepName [StepName ...] (run before this file; names from TestRunner:Steps) /// -- @teardown StepName [StepName ...] (run after this file, always, best-effort) /// -- @connection Name (run this file on a named ConnectionStrings entry) +/// -- @tag Name [Name ...] (file tags; filtered with TestRunner Tag/ExcludeTag) /// /// Annotations are repeatable and names accumulate in written order; multiple names on one line may be /// separated by whitespace or commas (`@setup A B` == `@setup A, B` — the NpgsqlRest annotation idiom). @@ -19,6 +20,8 @@ public sealed class TestFileHeader { public List Setup { get; } = []; public List Teardown { get; } = []; + /// File tags from -- @tag name [name ...] — filtered with TestRunner Tag/ExcludeTag. + public List Tags { get; } = []; public string? ConnectionName { get; private set; } public static TestFileHeader Parse(string content, string? sourceFile = null) @@ -63,6 +66,10 @@ private static bool Scan(string content, string? sourceFile, TestFileHeader head { header.ConnectionName = tokens[1]; } + else if (string.Equals(tokens[0], "tag", StringComparison.OrdinalIgnoreCase)) + { + header.Tags.AddRange(tokens[1..]); + } // any other @word is an ordinary comment — ignored continue; } diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 4f27bcc0..953f5a8e 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -38,6 +38,8 @@ public sealed class TestRunner private readonly bool _logNotices; private readonly Out _out = new(); private volatile bool _failFast; + // Endpoints actually invoked during the run (canonical "METHOD path" keys) — the coverage numerator. + private readonly ConcurrentDictionary _invoked = new(StringComparer.OrdinalIgnoreCase); private enum Outcome { Pass, Fail, Error } @@ -154,49 +156,305 @@ public async Task RunAsync(RoutineEndpoint[] endpoints, CancellationToken c { try { - var files = DiscoverFiles(); + return await ExecuteDiscoveredFilesAsync(endpoints, BuildEndpointLookup(endpoints), only: null, ct); + } + finally + { + await TeardownAsync(ct); + } + } + + // Discovers (or takes `only`), filters, runs, reports; returns the exit code. NO teardown — the caller + // owns that (RunAsync tears down in finally; watch mode tears down once, on exit). + private async Task ExecuteDiscoveredFilesAsync(RoutineEndpoint[] endpoints, IReadOnlyDictionary lookup, List? only, CancellationToken ct) + { + _failFast = false; // reset per run (watch mode reuses the runner across iterations) + if (only is null) _invoked.Clear(); // coverage counts per full run (partial watch reruns don't report) + + var files = only ?? DiscoverFiles(); + if (only is null) + { _log?.LogDebug("discovered {Count} test file(s) matching {Pattern}", files.Count, _opt.FilePattern); + } + if (!string.IsNullOrWhiteSpace(_opt.Filter)) + { + int discovered = files.Count; + files = files.Where(f => MatchesFilter(Path.GetRelativePath(Environment.CurrentDirectory, f), _opt.Filter!)).ToList(); + _log?.LogDebug("filter {Filter} matched {Count} of {Discovered} discovered file(s)", _opt.Filter, files.Count, discovered); if (files.Count == 0) { - _out.Line("Test runner: no test files matched FilePattern.", ConsoleColor.Yellow); + _out.Line($"Test runner: filter '{_opt.Filter}' matched none of the {discovered} discovered test file(s).", ConsoleColor.Yellow); return _opt.AllowEmpty ? ExitPass : ExitNoTests; } + } + if (_opt.Tags.Count > 0 || _opt.ExcludeTags.Count > 0) + { + int before = files.Count; + var include = new HashSet(_opt.Tags, StringComparer.OrdinalIgnoreCase); + var exclude = new HashSet(_opt.ExcludeTags, StringComparer.OrdinalIgnoreCase); + var selected = new List(files.Count); + foreach (var f in files) + { + List tags; + try + { + tags = TestFileHeader.Parse(await File.ReadAllTextAsync(f, ct), f).Tags; + } + catch + { + // Unreadable header (e.g. a broken include) — keep the file so the run surfaces the error. + selected.Add(f); + continue; + } + if (MatchesTags(tags, include, exclude)) selected.Add(f); + } + files = selected; + _log?.LogDebug("tag filter (tag: {Tags}; exclude: {Exclude}) matched {Count} of {Before} file(s)", + string.Join(',', _opt.Tags), string.Join(',', _opt.ExcludeTags), files.Count, before); + if (files.Count == 0) + { + _out.Line($"Test runner: tag filter matched none of the {before} test file(s).", ConsoleColor.Yellow); + return _opt.AllowEmpty ? ExitPass : ExitNoTests; + } + } + if (files.Count == 0) + { + _out.Line("Test runner: no test files matched FilePattern.", ConsoleColor.Yellow); + return _opt.AllowEmpty ? ExitPass : ExitNoTests; + } - var lookup = BuildEndpointLookup(endpoints); - var results = new ConcurrentBag(); - int dop = _opt.MaxParallelism > 0 ? _opt.MaxParallelism : Environment.ProcessorCount; + var results = new ConcurrentBag(); + int dop = _opt.MaxParallelism > 0 ? _opt.MaxParallelism : Environment.ProcessorCount; - // Console header stays a clean human report (just the file count, like other test runners). The - // parallelism is a diagnostic → log channel at Debug, not the always-on console line. - _out.Line($"NpgsqlRest test runner — {files.Count} file(s)", ConsoleColor.Cyan); - _log?.LogDebug("running {Count} test file(s) at degree of parallelism {Parallelism}", files.Count, dop); + // Console header stays a clean human report (just the file count, like other test runners). The + // parallelism is a diagnostic → log channel at Debug, not the always-on console line. + _out.Line($"NpgsqlRest test runner — {files.Count} file(s)", ConsoleColor.Cyan); + _log?.LogDebug("running {Count} test file(s) at degree of parallelism {Parallelism}", files.Count, dop); - await Parallel.ForEachAsync( - files, - new ParallelOptions { MaxDegreeOfParallelism = dop, CancellationToken = ct }, - async (file, _) => - { - if (_failFast) return; // stop scheduling new work after the first failure (in-flight finish) - var r = await RunFileAsync(file, lookup, ct); - results.Add(r); - if (_opt.FailFast && r.Outcome != Outcome.Pass) _failFast = true; - }); + await Parallel.ForEachAsync( + files, + new ParallelOptions { MaxDegreeOfParallelism = dop, CancellationToken = ct }, + async (file, _) => + { + if (_failFast) return; // stop scheduling new work after the first failure (in-flight finish) + var r = await RunFileAsync(file, lookup, ct); + results.Add(r); + if (_opt.FailFast && r.Outcome != Outcome.Pass) _failFast = true; + }); + + var ordered = results.OrderBy(r => r.File, StringComparer.Ordinal).ToList(); + ReportConsole(ordered); + if (!string.IsNullOrWhiteSpace(_opt.JUnitOutput)) + { + WriteJUnit(ordered, _opt.JUnitOutput!); + } + + int exit = ordered.Any(r => r.Outcome == Outcome.Error) ? ExitErrors + : ordered.Any(r => r.Outcome == Outcome.Fail) ? ExitFailures + : ExitPass; + if (_opt.Coverage && only is null) + { + exit = ReportCoverage(endpoints, exit); + } + return exit; + } - var ordered = results.OrderBy(r => r.File, StringComparer.Ordinal).ToList(); - ReportConsole(ordered); - if (!string.IsNullOrWhiteSpace(_opt.JUnitOutput)) + // Endpoint-coverage summary: exercised N of M TESTABLE endpoints + the untested list. Kinds the runner + // rejects (SSE, upload, login/logout, outbound proxy) are excluded from the ratio and counted apart. + // When CoverageThreshold is set and coverage falls below it, an otherwise-passing run fails with exit 2. + private int ReportCoverage(RoutineEndpoint[] endpoints, int exit) + { + var testable = new List(endpoints.Length); + int untestable = 0; + foreach (var ep in endpoints) + { + bool outboundProxy = ep.IsProxy && (ep.ProxyHost ?? _rest.ProxyOptions?.Host) is { } host && !host.StartsWith('/'); + if (ep.SseEventsPath is not null || ep.SsePublishEnabled || ep.Upload || ep.Login || ep.Logout || outboundProxy) { - WriteJUnit(ordered, _opt.JUnitOutput!); + untestable++; + continue; } + testable.Add(ep); + } + + var untested = testable.Where(ep => !_invoked.ContainsKey($"{ep.Method} {ep.Path}")).ToList(); + int covered = testable.Count - untested.Count; + int pct = testable.Count == 0 ? 100 : covered * 100 / testable.Count; + bool gateFailed = _opt.CoverageThreshold is int threshold && pct < threshold; + + var line = $"\nendpoint coverage: {covered}/{testable.Count} ({pct}%)" + + (untestable > 0 ? $" — {untestable} endpoint(s) excluded (not testable in test mode)" : ""); + if (gateFailed) _out.LineAnsi(line, AnsiFail); + else _out.Line(line, untested.Count == 0 ? ConsoleColor.Green : ConsoleColor.Yellow); - if (ordered.Any(r => r.Outcome == Outcome.Error)) return ExitErrors; - if (ordered.Any(r => r.Outcome == Outcome.Fail)) return ExitFailures; + foreach (var ep in untested) + { + _out.Line($" untested: {ep.Method} {ep.Path}", ConsoleColor.Yellow); + } + _log?.LogDebug("endpoint coverage {Covered}/{Testable} ({Pct}%), {Untestable} excluded", covered, testable.Count, pct, untestable); + + if (gateFailed) + { + _out.LineAnsi($" coverage {pct}% is below the CoverageThreshold ({_opt.CoverageThreshold}%)", AnsiFail); + if (exit == ExitPass) exit = ExitErrors; + } + return exit; + } + + /// + /// Tag filter: with include tags set, the file must carry at least one of them; a file carrying any + /// exclude tag is skipped (exclude wins). Sets must be case-insensitive (built once per run, so each + /// file is a single pass with O(1) membership checks). + /// + public static bool MatchesTags(IReadOnlyList fileTags, IReadOnlySet include, IReadOnlySet exclude) + { + bool included = include.Count == 0; + foreach (var tag in fileTags) + { + if (exclude.Count > 0 && exclude.Contains(tag)) + { + return false; + } + if (!included && include.Contains(tag)) + { + included = true; + } + } + return included; + } + + /// + /// Watch mode (interactive/dev-only): runs everything once, then re-runs on file changes until + /// Ctrl+C. A changed TEST file re-runs just that file; any other changed .sql under the watched tree + /// (e.g. a \ir fixture — its dependents are unknown) re-runs everything. Endpoint sources are NOT + /// watched (endpoints are built once at startup — restart to rebuild). Teardown runs once, on exit; + /// the exit code is always 0 on a graceful stop (watch is not for CI gating). + /// + public async Task RunWatchAsync(RoutineEndpoint[] endpoints, CancellationToken ct = default) + { + var lookup = BuildEndpointLookup(endpoints); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct); + // PosixSignalRegistration (not Console.CancelKeyPress, which is unreliable without a TTY): + // intercept both Ctrl+C (SIGINT) and e.g. `docker stop` (SIGTERM), cancel the default kill so the + // loop can end gracefully and Teardown can run. + void OnSignal(System.Runtime.InteropServices.PosixSignalContext ctx) + { + ctx.Cancel = true; + stop.Cancel(); + } + using var sigInt = System.Runtime.InteropServices.PosixSignalRegistration.Create( + System.Runtime.InteropServices.PosixSignal.SIGINT, OnSignal); + using var sigTerm = System.Runtime.InteropServices.PosixSignalRegistration.Create( + System.Runtime.InteropServices.PosixSignal.SIGTERM, OnSignal); + + try + { + await ExecuteDiscoveredFilesAsync(endpoints, lookup, only: null, stop.Token); + + var baseDir = GetWatchBaseDir(_opt.FilePattern); + if (baseDir is null) + { + _out.Line("Test runner: cannot watch — FilePattern has no existing base directory.", ConsoleColor.Yellow); + return ExitConfig; + } + + var changes = System.Threading.Channels.Channel.CreateUnbounded(); + using var watcher = new FileSystemWatcher(Path.GetFullPath(baseDir), "*.sql") + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + void OnFsEvent(object s, FileSystemEventArgs e) => changes.Writer.TryWrite(e.FullPath); + watcher.Changed += OnFsEvent; + watcher.Created += OnFsEvent; + watcher.Renamed += (s, e) => changes.Writer.TryWrite(e.FullPath); + watcher.EnableRaisingEvents = true; + + _out.Line($"\nwatching {Path.GetRelativePath(Environment.CurrentDirectory, baseDir)} for changes — Ctrl+C to stop", ConsoleColor.Cyan); + + while (!stop.IsCancellationRequested) + { + var changed = await NextChangeBatchAsync(changes.Reader, stop.Token); + if (changed is null) break; // cancelled + + // Debounced batch: changed test files rerun individually; any other .sql (fixture/profile + // include) forces a full rerun since its dependents are unknown. + var testFiles = new List(); + bool runAll = false; + foreach (var path in changed) + { + // Watcher paths are absolute; FilePattern is cwd-relative — compare in relative form. + var rel = Path.GetRelativePath(Environment.CurrentDirectory, path).Replace('\\', '/'); + var pattern = _opt.FilePattern.Replace('\\', '/'); + if (pattern.StartsWith("./")) pattern = pattern[2..]; + if (Parser.IsPatternMatch(rel, pattern)) + { + if (File.Exists(path)) testFiles.Add(Path.GetFullPath(path)); + } + else + { + runAll = true; + } + } + if (!runAll && testFiles.Count == 0) continue; // e.g. only deletions of test files + + var trigger = Path.GetRelativePath(Environment.CurrentDirectory, changed[0]); + _out.Line($"\n— {DateTime.Now:HH:mm:ss} change detected ({trigger}{(changed.Count > 1 ? $" +{changed.Count - 1}" : "")}) —", ConsoleColor.Cyan); + await ExecuteDiscoveredFilesAsync(endpoints, lookup, only: runAll ? null : testFiles.Distinct().ToList(), stop.Token); + _out.Line($"\nwatching — Ctrl+C to stop", ConsoleColor.Cyan); + } + return ExitPass; + } + catch (OperationCanceledException) + { return ExitPass; } finally { - await TeardownAsync(ct); + // Teardown must survive the SIGINT/SIGTERM that ended the loop → uncancellable token. + await TeardownAsync(CancellationToken.None); + } + } + + // First change (blocking), then a quiet period so editor write bursts coalesce into one rerun. + private static async Task?> NextChangeBatchAsync(System.Threading.Channels.ChannelReader reader, CancellationToken ct) + { + string first; + try + { + first = await reader.ReadAsync(ct); + } + catch (OperationCanceledException) + { + return null; + } + var batch = new List { first }; + while (true) + { + try { await Task.Delay(300, ct); } catch (OperationCanceledException) { return null; } + bool any = false; + while (reader.TryRead(out var more)) + { + batch.Add(more); + any = true; + } + if (!any) return batch; + } + } + + // The deepest fixed (wildcard-free) directory of the FilePattern — same logic DiscoverFiles uses. + private static string? GetWatchBaseDir(string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) return null; + int firstWildcard = pattern.IndexOfAny(['*', '?']); + if (firstWildcard < 0) + { + var dir = Path.GetDirectoryName(pattern); + return string.IsNullOrEmpty(dir) ? "." : dir; } + int lastSlash = pattern.LastIndexOf('/', firstWildcard); + var baseDir = lastSlash >= 0 ? pattern[..lastSlash] : "."; + return Directory.Exists(baseDir) ? baseDir : null; } private async Task RunFileAsync(string file, IReadOnlyDictionary lookup, CancellationToken runCt) @@ -490,6 +748,10 @@ private async Task InvokeHttpStepAsync( Ambient.Value = conn; // ensure this file's connection is used by the endpoint pipeline _log?.LogTrace("http {Method} {Path} (line {Line})", step.Method, step.Path, step.LineNumber); var response = await RoutineInvoker.InvokeAsync(step.Method, step.Path, headers, step.Body, contentType, user, ct); + if (ep is not null) + { + _invoked[$"{ep.Method} {ep.Path}"] = 1; // endpoint-coverage numerator (canonical key) + } _log?.LogTrace("http {Method} {Path} → {Status}, captured into temp table \"{Table}\"", step.Method, step.Path, response.StatusCode, responseTable); @@ -818,6 +1080,22 @@ private static void WriteJUnit(List results, string path) new XDocument(new XDeclaration("1.0", "utf-8", null), suite).Save(path); } + /// + /// Filter match for TestRunner.Filter against a cwd-relative file path: a value without + /// wildcards is a case-insensitive substring match (`login` == `*login*`); with wildcards it uses + /// the same glob engine as FilePattern. Path separators are normalized to '/'. + /// + public static bool MatchesFilter(string relativePath, string filter) + { + var path = relativePath.Replace('\\', '/'); + filter = filter.Trim().Replace('\\', '/'); + if (filter.IndexOfAny(['*', '?']) < 0) + { + return path.Contains(filter, StringComparison.OrdinalIgnoreCase); + } + return Parser.IsPatternMatch(path, filter); + } + private static string StripQuery(string path) { int q = path.IndexOf('?'); diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index d61133e9..935b527f 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -6,6 +6,36 @@ public class TestRunnerOptions /// Glob (same engine as SqlFileSource) selecting *.test.sql files. Empty disables the runner. public string FilePattern { get; set; } = ""; + /// + /// Optional filter narrowing the discovered set — the fast path for iterating on one test: + /// npgsqlrest ... --test --testrunner:filter=login. Matched against each file's cwd-relative + /// path: a value without wildcards is a substring match; with wildcards it is the same glob engine + /// as . Empty = run everything discovered. + /// + public string? Filter { get; set; } = null; + + /// + /// Run only files carrying at least one of these tags (from a -- @tag name [name ...] header + /// annotation). Empty = no tag requirement. Composes with and . + /// + public List Tags { get; set; } = []; + + /// Skip files carrying any of these tags (e.g. exclude "slow" locally). Empty = skip nothing. + public List ExcludeTags { get; set; } = []; + + /// + /// Emit an endpoint-coverage summary after the run: exercised N of M testable endpoints plus the list + /// of untested ones. Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are + /// excluded from the ratio and reported separately. + /// + public bool Coverage { get; set; } = false; + + /// + /// Fail an otherwise-passing run (exit 2) when endpoint coverage is below this percentage (0-100), for + /// CI gating. Setting it implies . Null = no gate. + /// + public int? CoverageThreshold { get; set; } = null; + /// /// Name of a ConnectionStrings entry to run the tests against, instead of the app's main connection. /// In test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it @@ -41,6 +71,14 @@ public class TestRunnerOptions /// Zero tests discovered => exit 0 instead of 4. public bool AllowEmpty { get; set; } = false; + /// + /// Watch mode (interactive/dev-only; CLI: --watch): run everything once, then re-run on file + /// changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched + /// tree re-runs everything (an included fixture's dependents are unknown). Endpoint sources are not + /// watched — restart to rebuild endpoints. Teardown runs once, on exit; graceful exit code is 0. + /// + public bool Watch { get; set; } = false; + /// /// SourceContext name for the runner's own log channel — discovery/parsing at Debug, each query and HTTP /// invocation at Verbose, captured `raise notice` by its severity. Set its level independently via diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index e6e83f68..1b00ba09 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1439,6 +1439,21 @@ // "FilePattern": "", // + // Optional filter narrowing the discovered set — the fast path for iterating on one test: + // npgsqlrest ... --test --testrunner:filter=login + // Matched against each file's cwd-relative path: a value without wildcards is a substring match; + // with wildcards it is the same glob engine as FilePattern. Empty = run everything discovered. + // + "Filter": "", + // + // Tag filtering (comma- or whitespace-separated lists; case-insensitive). A test file declares tags + // with a `-- @tag name [name ...]` header annotation. "Tag" runs only files carrying at least one of + // the listed tags; "ExcludeTag" skips files carrying any of them (exclude wins). Composes with Filter. + // npgsqlrest ... --test --testrunner:tag=smoke --testrunner:excludetag=slow + // + "Tag": "", + "ExcludeTag": "", + // // Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection. In // test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can // point at a dedicated test database that a Setup step creates first (it need not exist at startup). @@ -1478,6 +1493,21 @@ // "AllowEmpty": false, // + // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file + // changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched + // tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to + // rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating). + // + "Watch": false, + // + // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested + // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded + // from the ratio and counted separately). CoverageThreshold (0-100, implies Coverage) fails an + // otherwise-passing run with exit 2 when coverage is below it — CI gating for "every endpoint has a test". + // + "Coverage": false, + "CoverageThreshold": null, + // // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. // Discovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. // diff --git a/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs b/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs index 9be06a46..3b1f6623 100644 --- a/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs +++ b/NpgsqlRestTests/TestRunnerTests/ParserTests/TestFileHeaderAndIncludeTests.cs @@ -50,6 +50,46 @@ public void Unknown_Annotations_And_Bare_Keywords_Are_Ignored() h.Teardown.Should().BeEmpty(); h.ConnectionName.Should().BeNull(); } + + [Fact] + public void Tags_Are_Parsed_With_Whitespace_Or_Commas() + { + var h = TestFileHeader.Parse("-- @tag smoke, regression\n-- @tag slow\nselect 1;"); + h.Tags.Should().Equal("smoke", "regression", "slow"); + } +} + +public class TagFilterTests +{ + [Theory] + [InlineData(new[] { "smoke" }, new string[0], new string[0], true)] // no filters => runs + [InlineData(new[] { "smoke" }, new[] { "smoke" }, new string[0], true)] // include hit + [InlineData(new[] { "smoke" }, new[] { "SMOKE" }, new string[0], true)] // case-insensitive + [InlineData(new string[0], new[] { "smoke" }, new string[0], false)] // include set, file untagged + [InlineData(new[] { "smoke", "slow" }, new string[0], new[] { "slow" }, false)] // exclude hit + [InlineData(new[] { "smoke", "slow" }, new[] { "smoke" }, new[] { "slow" }, false)] // exclude wins over include + public void Tag_Filter_Matches(string[] fileTags, string[] include, string[] exclude, bool expected) + { + NpgsqlRestClient.Testing.TestRunner.MatchesTags( + fileTags, + new HashSet(include, StringComparer.OrdinalIgnoreCase), + new HashSet(exclude, StringComparer.OrdinalIgnoreCase)).Should().Be(expected); + } +} + +public class FilterMatchTests +{ + [Theory] + [InlineData("tests/login_succeeds.test.sql", "login", true)] // no wildcard => substring + [InlineData("tests/login_succeeds.test.sql", "LOGIN", true)] // substring is case-insensitive + [InlineData("tests/get_users.test.sql", "login", false)] + [InlineData("tests/login_succeeds.test.sql", "*login*", true)] // wildcard => glob + [InlineData("tests/sub/deep.test.sql", "tests/sub/*", true)] + [InlineData("tests\\win\\style.test.sql", "tests/win/*", true)] // separators normalized + public void Filter_Matches_Substring_Or_Glob(string path, string filter, bool expected) + { + NpgsqlRestClient.Testing.TestRunner.MatchesFilter(path, filter).Should().Be(expected); + } } public class IncludeParseTests diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index fefdf6c5..4a5be38d 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -183,6 +183,7 @@ An individual test file can attach named steps — and pick its own connection - `-- @setup Name [Name …]` — runs the named steps **before this file** (own connections, committed work — e.g. clone a database this file will use). An unknown step name fails the file with an error. - `-- @teardown Name [Name …]` — runs **after this file, always** (best-effort, even when the test fails or times out), after the file's connection is closed — so a `drop database … with (force)` teardown works. An unknown step name logs a warning. - `-- @connection Name` — runs this file, **including its in-process endpoint calls**, on a named `ConnectionStrings` entry instead of the test connection. An unknown name fails the file with an error. +- `-- @tag Name [Name …]` — declares the file's tags, filtered with the `Tag`/`ExcludeTag` options (see the filtering section below). `@setup` and `@teardown` are **repeatable**, and one line may carry **several names, separated by whitespace or commas** (the NpgsqlRest annotation idiom — `-- @setup A B`, `-- @setup A, B`, and two `-- @setup` lines are all equivalent). Names accumulate and execute in **exactly the order written** — the same contract as the global `Setup`/`Teardown` arrays; teardown is *not* reversed, so write the step you want last, last. Setup is fail-fast (the first failing or unknown step stops the chain, the file body never runs, teardown still runs); each teardown step is best-effort (a failure logs a warning and the remaining steps still run). @@ -256,6 +257,9 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v ```jsonc "TestRunner": { "FilePattern": "", // glob selecting test files (same engine as SqlFileSource); empty disables + "Filter": "", // narrow the discovered set: substring, or glob when it contains wildcards + "Tag": "", // run only files carrying at least one of these tags (-- @tag name ...) + "ExcludeTag": "", // skip files carrying any of these tags (wins over Tag) "ConnectionName": "", // ConnectionStrings entry to test against; empty = the main connection "MaxParallelism": 0, // concurrent test files; 0 = processor count "FailFast": false, // stop scheduling new files after the first failure (in-flight finish) @@ -264,6 +268,9 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "Keep": false, // skip Teardown (inspect state after a failed run) "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests "AllowEmpty": false, // exit 0 instead of 4 when no tests are found + "Watch": false, // watch mode (CLI: --watch): re-run on file changes until Ctrl+C; teardown on exit + "Coverage": false, // endpoint-coverage summary: exercised N/M testable + the untested list + "CoverageThreshold": null, // 0-100 (implies Coverage): fail an otherwise-passing run (exit 2) below it "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) "ResponseTempTable": { "Name": "_response", // table name when a file has ONE HTTP block @@ -281,6 +288,45 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v A practical convention is to keep the `TestRunner` block (and quiet log levels) in a separate `test-config.json` layered on only for test runs: `npgsqlrest ./config.json ./test-config.json --test`. +**Iterating on one test:** `Filter` narrows the run to matching files, and like every option it can be set from the command line: + +``` +npgsqlrest ./config.json ./test-config.json --test --testrunner:filter=login +``` + +A value without wildcards is a case-insensitive **substring** match against each file's cwd-relative path (`login` runs every `*login*` file); a value with wildcards uses the same glob engine as `FilePattern` (`**/get_users_shows*`). `Setup` and `Teardown` still run — the filtered subset executes in the complete environment — and a filter that matches nothing exits with code `4` (`AllowEmpty` applies). + +**Tags** group tests orthogonally to the directory layout. A file declares them with a header annotation, and runs are narrowed with `Tag` (include — the file must carry at least one) and `ExcludeTag` (skip — wins over include); both accept comma- or whitespace-separated lists, case-insensitive, and compose with `Filter`: + +```sql +-- @tag smoke, regression +``` + +``` +npgsqlrest ... --test --testrunner:tag=smoke --testrunner:excludetag=slow +``` + +Since includes behave as if pasted, tags travel through a **shared profile** too: a profile file carrying `-- @tag isolation, slow` next to its `-- @setup`/`-- @connection` annotations tags *every test that attaches it* — e.g. all clone-isolated tests are automatically `slow`, so the everyday dev loop is just `--testrunner:excludetag=slow`, with zero per-file bookkeeping. + +**Endpoint coverage** (`Coverage: true`, or just set a threshold) is something only an integrated runner can offer: the runner knows the **entire API surface** it built *and* records every endpoint the tests actually invoked, so after the run it reports the API-level analogue of code coverage — including the exact endpoints no test touches: + +``` +19 passed, 0 failed, 0 error(s) — 19 assertions in 9 files + +endpoint coverage: 1/2 (50%) + untested: GET /api/get-users +``` + +Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are excluded from the ratio and counted separately, so the number is honest. **`CoverageThreshold`** (0–100, implies `Coverage`) turns it into a CI gate: an otherwise-passing run below the threshold exits `2` — set it to `100` and forgetting to write a test for a new endpoint fails the build, naming the endpoint. "Covered" means *invoked at least once by a test* — execution, not assertion depth (the same semantics as code coverage). + +**Watch mode** (`--watch`, or `TestRunner:Watch`) keeps the process alive and re-runs on change — and because the endpoint middleware is built once at startup, re-runs are near-instant: + +``` +npgsqlrest ./config.json ./test-config.json --test --watch +``` + +`Setup` runs once, then everything runs once, then the `FilePattern` base directory is watched recursively for `*.sql` changes (debounced): a changed **test file** re-runs alone (the `Filter` still applies); any **other** changed `.sql` — an included fixture or profile, whose dependents are unknown — re-runs everything. `Teardown` runs **once, on exit**: Ctrl+C (SIGINT) and SIGTERM (e.g. `docker stop`) are intercepted so the test database is still dropped cleanly. Interactive/dev-only: a graceful stop exits `0` regardless of test outcomes — watch is not for CI gating. Endpoint *sources* are not watched (endpoints are built once) — restart to rebuild them. + ### Project layout Two equally supported conventions — the difference is just the globs: @@ -322,8 +368,8 @@ Each named logger is controlled independently — e.g. mute the application logg - The test runner's core hooks are additive and inert outside `--test`: an ambient-connection accessor on the endpoint pipeline (null by default) and response headers on the internal invocation result. Normal server operation is unchanged. - Test files run **statement by statement** (the client operates Npgsql with SQL rewriting disabled — one statement per command), which is also `psql`'s default execution model; explicit `begin`/`commit`/`rollback` in a file work as ordinary statements. `Setup`/`Teardown` `Sql`/`SqlFile` steps execute the same way — which is why `create database` works as a plain step. - All new options are wired through the client configuration: `appsettings.json`, the JSON-schema descriptions, and the `--config` template. -- Working examples: [`examples/19_testing_basic`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/19_testing_basic) (co-located layout, multi-step scenario files), [`examples/20_testing_newdb`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/20_testing_newdb) (separate `tests/` tree, one test per file, fresh test database per run via named steps, deferrable-constraint fixtures, authorization + user parameters), and [`examples/21_testing_isolation`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/21_testing_isolation) (template-clone workflow; two parallel per-test isolated databases — named apart with the indexed `{rnd5_1}`/`{rnd5_2}` tokens — proving deterministic sequence ids; a shared annotation profile attached via `\ir`; a `Command` step mixed with named-step references in Setup). +- Working examples: [`examples/19_testing_basic`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/19_testing_basic) (co-located layout, multi-step scenario files), [`examples/20_testing_newdb`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/20_testing_newdb) (separate `tests/` tree, one test per file, fresh test database per run via named steps, deferrable-constraint fixtures, authorization + user parameters, a tag taxonomy — `smoke`/`auth`/`fixtures`/`login` — on every file), and [`examples/21_testing_isolation`](https://github.com/NpgsqlRest/npgsqlrest-docs/tree/master/examples/21_testing_isolation) (template-clone workflow; two parallel per-test isolated databases — named apart with the indexed `{rnd5_1}`/`{rnd5_2}` tokens — proving deterministic sequence ids; a shared annotation profile attached via `\ir` that also carries the `isolation, slow` tags; a `Command` step mixed with named-step references in Setup). ## Tests -Full test suite green (2351), including 50 parser unit tests for the test-file, HTTP-block, header-annotation, and include parsers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown). The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). +Full test suite green (2364), including 63 unit tests for the test-file, HTTP-block, header-annotation, and include parsers plus the filter and tag matchers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown), watch mode (single-file rerun on a test change, full rerun on a fixture change, teardown on SIGINT/SIGTERM), path and tag filtering (including tags carried through a profile include), and the coverage report with a failing threshold gate. The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). From 45a5e81d2b374a8efc7423768222572411d774ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 20:06:29 +0200 Subject: [PATCH 05/14] feat(test-runner): watch rebuilds endpoints, guaranteed teardown, themed report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch mode — endpoint source tree (SqlFileSource): - When the SQL file source is enabled, watch mode also watches its tree: a changed endpoint file triggers an in-process endpoint rebuild (UseNpgsqlRest re-reads + re-describes against the test database and atomically swaps the endpoint registry; a failed rebuild keeps the previous endpoints) followed by a full rerun. The endpoint delta is reported after each rebuild (+ METHOD path / - METHOD path "endpoint dropped - check its SQL file"), so breaking an endpoint mid-session is visible immediately and fixing it recovers - no restart. Watch mode forces SqlFileSource.ErrorMode from Exit to Skip (a broken file must not kill the session); non-watch --test keeps Exit for CI. Guaranteed teardown (fixes two database-leak classes): - From Setup onward, SIGINT (Ctrl+C) and SIGTERM are intercepted and Teardown runs SYNCHRONOUSLY in the signal handler - wrappers like `bun run` forward Ctrl+C to the process group and kill their children immediately, so the old cooperative unwind lost the race and leaked the test database. A second signal force-quits. Non-watch --test previously had no handler at all. - An AppDomain.ProcessExit hook covers hard exits: SqlFileSource ErrorMode=Exit's Environment.Exit(1) previously leaked the just-created test database; exit code unchanged, Teardown now runs first. - All paths funnel into a run-once teardown that hands every caller the SAME task (a fire-once no-op guard let the process exit mid-DROP). - An interrupted non-watch run exits 2. Console report themed to Serilog's Code theme: - FAIL/ERROR labels render as the byte-identical chip the Code theme uses for its ERR/FTL level (red-197 on grey-238); PASS uses the same chip grammar in the mirror green-47; line text stays in the terminal's normal color; ALL failure text unified on the theme's red-197 foreground (two stragglers used 16-color ConsoleColor.Red, which renders orange in some themes). Out.LineChip renders chips, plain when redirected. Changelog updated (endpoint watching, teardown guarantees, report theming, exit codes). Full suite green (2364); verified live: benign endpoint edit -> rebuild + green, broken endpoint -> dropped-endpoint delta + failing tests, fix -> recovery; broken-endpoint hard exit -> exit 1 with ZERO leaked databases; watch SIGTERM -> full teardown, zero leftovers. --- NpgsqlRestClient/App.cs | 10 +- NpgsqlRestClient/ConfigSchemaGenerator.cs | 2 +- NpgsqlRestClient/ConfigTemplate.cs | 8 +- NpgsqlRestClient/Out.cs | 25 +- NpgsqlRestClient/Program.cs | 39 ++- NpgsqlRestClient/Testing/TestRunner.cs | 250 ++++++++++++++---- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 7 +- NpgsqlRestClient/appsettings.json | 8 +- changelog/v3.19.0.md | 16 +- 9 files changed, 288 insertions(+), 77 deletions(-) diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index ba97954b..8978f83b 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -621,7 +621,7 @@ public List CreateCodeGenHandlers(string connectionStrin return handlers; } - public List CreateEndpointSources() + public List CreateEndpointSources(bool relaxSqlFileErrors = false) { var sources = new List(2); @@ -684,6 +684,14 @@ public List CreateEndpointSources() { opts.ErrorMode = _config.GetConfigEnum("ErrorMode", sqlFileSourceCfg); } + if (relaxSqlFileErrors && opts.ErrorMode == ParseErrorMode.Exit) + { + // Watch mode rebuilds endpoints on the fly; ErrorMode=Exit would kill the whole watch + // session (Environment.Exit) on a broken SQL file — Skip drops the file instead, and + // the runner reports the endpoint delta after each rebuild. + opts.ErrorMode = ParseErrorMode.Skip; + _builder.TestLogger?.LogDebug("watch mode: SqlFileSource ErrorMode forced from Exit to Skip"); + } var resultPrefix = _config.GetConfigStr("ResultPrefix", sqlFileSourceCfg); if (resultPrefix is not null) { diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 895d01e5..512469ab 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -259,7 +259,7 @@ public static partial class ConfigSchemaGenerator ["TestRunner:Keep"] = "Skip Teardown so a failed run's state can be inspected.", ["TestRunner:DetailedReport"] = "Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show captured `raise notice` output for passing tests too.\nThis shapes the report only — for diagnostic logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName).", ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", - ["TestRunner:Watch"] = "Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file changes until Ctrl+C.\nA changed test file re-runs alone; any other changed .sql under the watched tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating).", + ["TestRunner:Watch"] = "Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file changes until Ctrl+C.\nA changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a broken file cannot kill the session) and re-runs everything; any other changed .sql under the test tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating).", ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 7382ddcc..523c6848 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1503,9 +1503,11 @@ public static partial class ConfigSchemaGenerator "AllowEmpty": false, // // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file - // changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched - // tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to - // rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating). + // changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource + // FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a + // broken file cannot kill the session) and re-runs everything; any other changed .sql under the test + // tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop + // exits 0 (not for CI gating). // "Watch": false, // diff --git a/NpgsqlRestClient/Out.cs b/NpgsqlRestClient/Out.cs index f1c9c05a..c690db96 100644 --- a/NpgsqlRestClient/Out.cs +++ b/NpgsqlRestClient/Out.cs @@ -47,7 +47,7 @@ public void Write(string line, ConsoleColor? color = null) } /// - /// Write a line wrapped in a raw ANSI SGR sequence (e.g. "\x1b[38;5;196m" for a 256-color foreground), + /// Write a line wrapped in a raw ANSI SGR sequence (e.g. "\x1b[38;5;197m" for a 256-color foreground), /// used when a specific color is needed that the 16-color API can't express. /// Emits plain text (no escapes) when output is redirected, so piped/CI logs stay clean. /// @@ -63,6 +63,29 @@ public void LineAnsi(string line, string ansiSgr) Console.WriteLine("\x1b[0m"); } + /// + /// Write a line starting with a colored "chip" (the label rendered in its own SGR — e.g. white text on + /// a red background, like Serilog's ERR level chip) followed by the rest of the line, optionally in + /// another SGR. Emits plain text when output is redirected. + /// + public void LineChip(string chip, string chipSgr, string rest, string? restSgr = null) + { + if (Console.IsOutputRedirected) + { + Console.WriteLine(string.Concat(chip, rest)); + return; + } + Console.Write(chipSgr); + Console.Write(chip); + Console.Write("\x1b[0m"); + if (restSgr is not null) + { + Console.Write(restSgr); + } + Console.Write(rest); + Console.WriteLine("\x1b[0m"); + } + public void JsonHighlight(string json) { if (Console.IsOutputRedirected) diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index c17764f9..ea0f29c1 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -271,6 +271,10 @@ bool endpointsMode = args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase)); bool testMode = args.Any(a => string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase)); +// Watch mode (interactive re-run loop). Needed this early because it relaxes SqlFileSource error handling +// (a broken endpoint file must not kill the watch session — see CreateEndpointSources). +bool watchMode = testMode && (args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) + || config.GetConfigBool("Watch", config.Cfg.GetSection("TestRunner"))); builder.BuildInstance(); builder.Instance.Services.AddRouting(); @@ -474,7 +478,7 @@ SseResponseHeaders = builder.GetSseResponseHeaders(), WarnUnboundSseNotices = config.GetConfigBool("WarnUnboundServerSentEventsNotices", config.NpgsqlRestCfg, true), - EndpointSources = appInstance.CreateEndpointSources(), + EndpointSources = appInstance.CreateEndpointSources(relaxSqlFileErrors: watchMode), UploadOptions = appInstance.CreateUploadOptions(), CacheOptions = builder.BuildCacheOptions(app, cacheType), @@ -510,7 +514,7 @@ catch (Exception ex) { // Configuration error (e.g. a Setup/Teardown entry referencing an unknown named step). - new Out().Line($"Test runner configuration error: {ex.Message}", ConsoleColor.Red); + new Out().LineAnsi($"Test runner configuration error: {ex.Message}", NpgsqlRestClient.Testing.TestRunner.AnsiFail); builder.TestLogger?.LogError(ex, "Test runner configuration error"); Environment.ExitCode = NpgsqlRestClient.Testing.TestRunner.ExitConfig; return; @@ -538,12 +542,31 @@ if (testMode) { // Endpoints are now built; run the test files (then Teardown), and exit. `--watch` (or - // TestRunner:Watch) keeps the process alive and re-runs on file changes until Ctrl+C. - bool watchMode = args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) - || testRunnerOptions!.Watch; - Environment.ExitCode = watchMode - ? await testRunner!.RunWatchAsync(EndpointCapture.Endpoints) - : await testRunner!.RunAsync(EndpointCapture.Endpoints); + // TestRunner:Watch) keeps the process alive and re-runs on file changes until Ctrl+C. When the SQL + // file source is enabled, watch mode also watches its tree: a changed endpoint file triggers an + // in-process endpoint rebuild (UseNpgsqlRest re-reads + re-describes and atomically swaps the + // endpoint registry) followed by a full test rerun. + if (watchMode) + { + string? endpointPattern = null; + var sqlFileCfg = config.NpgsqlRestCfg.GetSection("SqlFileSource"); + if (sqlFileCfg.Exists() && config.GetConfigBool("Enabled", sqlFileCfg, true)) + { + endpointPattern = config.GetConfigStr("FilePattern", sqlFileCfg); + } + Environment.ExitCode = await testRunner!.RunWatchAsync( + EndpointCapture.Endpoints, + endpointPattern, + () => + { + app.UseNpgsqlRest(options); + return EndpointCapture.Endpoints; + }); + } + else + { + Environment.ExitCode = await testRunner!.RunAsync(EndpointCapture.Endpoints); + } return; } diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 953f5a8e..35ccafbd 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -16,9 +16,16 @@ namespace NpgsqlRestClient.Testing; /// public sealed class TestRunner { - // Failure/error color: ANSI 256-color 196 — the same red Serilog's Code theme uses (truer than the - // 16-color ConsoleColor.Red, which renders orange in some themes). Emitted only to a real terminal. - private const string AnsiFail = "\x1b[38;5;196m"; + // Report styling, matched to Serilog's Code theme: the FAIL/ERROR label renders as the byte-identical + // CHIP the Code theme uses for its ERR/FTL level — red-197 text on a dark-grey-238 background (NOT the + // white-on-red-196 chip, which is the Literate theme's). PASS uses the same chip grammar with green-47, + // the exact color-cube mirror of red-197 (#ff005f ↔ #00ff5f). ALL failure text uses the red-197 + // foreground (never 16-color ConsoleColor.Red, which renders orange in some themes). Escapes are + // emitted only on a real terminal (see Out). + private const string AnsiFailChip = "\x1b[38;5;197m\x1b[48;5;238m"; + private const string AnsiPassChip = "\x1b[38;5;47m\x1b[48;5;238m"; + public const string AnsiFail = "\x1b[38;5;197m"; + private const string AnsiOk = "\x1b[38;5;47m"; public const int ExitPass = 0; public const int ExitFailures = 1; @@ -41,6 +48,19 @@ public sealed class TestRunner // Endpoints actually invoked during the run (canonical "METHOD path" keys) — the coverage numerator. private readonly ConcurrentDictionary _invoked = new(StringComparer.OrdinalIgnoreCase); + // Lifetime cleanup: from Setup onward, SIGINT/SIGTERM and process exit all funnel into a run-once + // Teardown, executed SYNCHRONOUSLY in the signal handler — not after the run loop unwinds. A parent + // process (bun/npm run, a shell) that kills its children right after forwarding Ctrl+C would otherwise + // win the race and leak the test database. ProcessExit additionally covers hard exits such as + // SqlFileSource ErrorMode=Exit calling Environment.Exit(1). (A parent that SIGKILLs instantly is not + // survivable — this shrinks the window to the teardown itself.) + private readonly CancellationTokenSource _stop = new(); + private readonly object _teardownLock = new(); + private Task? _teardownTask; + private int _signalCount; + private System.Runtime.InteropServices.PosixSignalRegistration? _sigInt; + private System.Runtime.InteropServices.PosixSignalRegistration? _sigTerm; + private enum Outcome { Pass, Fail, Error } // Result of executing one step. Emit=false marks an arrange/act step that is not a reported test (it @@ -93,9 +113,48 @@ public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConn _rest.AmbientConnectionAccessor = () => Ambient.Value; } + // Registers the run-once cleanup triggers. First SIGINT/SIGTERM: cancel everything, then run Teardown + // right here on the signal thread (ctx.Cancel keeps the process alive for it); a second signal force- + // quits. ProcessExit is the safety net for Environment.Exit paths and no-ops after a normal teardown. + private void RegisterLifetimeCleanup() + { + void OnSignal(System.Runtime.InteropServices.PosixSignalContext ctx) + { + if (Interlocked.Increment(ref _signalCount) > 1) + { + return; // second signal: leave ctx.Cancel=false → default handling kills the process + } + ctx.Cancel = true; + _stop.Cancel(); + try { TeardownOnceAsync().GetAwaiter().GetResult(); } catch { /* best-effort */ } + } + _sigInt = System.Runtime.InteropServices.PosixSignalRegistration.Create( + System.Runtime.InteropServices.PosixSignal.SIGINT, OnSignal); + _sigTerm = System.Runtime.InteropServices.PosixSignalRegistration.Create( + System.Runtime.InteropServices.PosixSignal.SIGTERM, OnSignal); + AppDomain.CurrentDomain.ProcessExit += (s, e) => + { + try { TeardownOnceAsync().GetAwaiter().GetResult(); } catch { /* best-effort */ } + }; + } + + // All teardown paths (signal, process exit, RunAsync/RunWatchAsync finally, Setup failure) funnel here. + // The first caller starts it; every caller gets the SAME task — so a concurrent caller (e.g. the run + // loop's finally racing the signal thread) awaits the in-flight teardown instead of letting the process + // exit while databases are still being dropped. + private Task TeardownOnceAsync() + { + lock (_teardownLock) + { + _teardownTask ??= TeardownAsync(CancellationToken.None); + return _teardownTask; + } + } + /// Runs before endpoint discovery: collision check + Setup steps. Returns false on failure (logged, teardown attempted). public async Task SetupAsync(CancellationToken ct = default) { + RegisterLifetimeCleanup(); try { // Response-table names are validated and created per HTTP block (no pre-run permanent-table @@ -114,9 +173,9 @@ public async Task SetupAsync(CancellationToken ct = default) } catch (Exception ex) { - _out.Line($"Test runner setup failed: {ex.Message}", ConsoleColor.Red); + _out.LineAnsi($"Test runner setup failed: {ex.Message}", AnsiFail); _log?.LogError(ex, "Test runner setup failed"); - await TeardownAsync(ct); + await TeardownOnceAsync(); return false; } } @@ -154,13 +213,19 @@ private string ResolveStepConnection(TestSetupStep step) /// Runs after endpoint discovery: executes the test files and (always) Teardown. Returns the process exit code. public async Task RunAsync(RoutineEndpoint[] endpoints, CancellationToken ct = default) { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token, ct); try { - return await ExecuteDiscoveredFilesAsync(endpoints, BuildEndpointLookup(endpoints), only: null, ct); + return await ExecuteDiscoveredFilesAsync(endpoints, BuildEndpointLookup(endpoints), only: null, linked.Token); + } + catch (OperationCanceledException) + { + // Interrupted (Ctrl+C/SIGTERM) — the signal handler has already run Teardown synchronously. + return ExitErrors; } finally { - await TeardownAsync(ct); + await TeardownOnceAsync(); } } @@ -325,81 +390,123 @@ public static bool MatchesTags(IReadOnlyList fileTags, IReadOnlySet /// Watch mode (interactive/dev-only): runs everything once, then re-runs on file changes until - /// Ctrl+C. A changed TEST file re-runs just that file; any other changed .sql under the watched tree - /// (e.g. a \ir fixture — its dependents are unknown) re-runs everything. Endpoint sources are NOT - /// watched (endpoints are built once at startup — restart to rebuild). Teardown runs once, on exit; - /// the exit code is always 0 on a graceful stop (watch is not for CI gating). + /// Ctrl+C. A changed TEST file re-runs just that file; a changed ENDPOINT file (matching + /// , when the SQL file source is enabled) triggers an in-process + /// endpoint REBUILD via followed by a full rerun; any other + /// changed .sql under the test tree (e.g. a \ir fixture — its dependents are unknown) re-runs + /// everything. Teardown runs once, on exit; the exit code is always 0 on a graceful stop (watch is + /// not for CI gating). /// - public async Task RunWatchAsync(RoutineEndpoint[] endpoints, CancellationToken ct = default) + public async Task RunWatchAsync( + RoutineEndpoint[] endpoints, + string? endpointPattern = null, + Func? rebuildEndpoints = null, + CancellationToken ct = default) { var lookup = BuildEndpointLookup(endpoints); - using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct); - // PosixSignalRegistration (not Console.CancelKeyPress, which is unreliable without a TTY): - // intercept both Ctrl+C (SIGINT) and e.g. `docker stop` (SIGTERM), cancel the default kill so the - // loop can end gracefully and Teardown can run. - void OnSignal(System.Runtime.InteropServices.PosixSignalContext ctx) - { - ctx.Cancel = true; - stop.Cancel(); - } - using var sigInt = System.Runtime.InteropServices.PosixSignalRegistration.Create( - System.Runtime.InteropServices.PosixSignal.SIGINT, OnSignal); - using var sigTerm = System.Runtime.InteropServices.PosixSignalRegistration.Create( - System.Runtime.InteropServices.PosixSignal.SIGTERM, OnSignal); + // SIGINT/SIGTERM handling is registered runner-wide (see RegisterLifetimeCleanup): the FIRST signal + // cancels this token and runs Teardown synchronously on the signal thread — so cleanup wins even + // when a parent process (bun/npm run) kills its children right after forwarding Ctrl+C. + using var stop = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token, ct); try { await ExecuteDiscoveredFilesAsync(endpoints, lookup, only: null, stop.Token); - var baseDir = GetWatchBaseDir(_opt.FilePattern); - if (baseDir is null) + var testBase = GetWatchBaseDir(_opt.FilePattern); + if (testBase is null) { _out.Line("Test runner: cannot watch — FilePattern has no existing base directory.", ConsoleColor.Yellow); return ExitConfig; } + var testBaseFull = Path.GetFullPath(testBase); + var endpointBaseFull = rebuildEndpoints is not null && !string.IsNullOrWhiteSpace(endpointPattern) + ? GetWatchBaseDir(endpointPattern!) is { } eb ? Path.GetFullPath(eb) : null + : null; var changes = System.Threading.Channels.Channel.CreateUnbounded(); - using var watcher = new FileSystemWatcher(Path.GetFullPath(baseDir), "*.sql") - { - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, - }; void OnFsEvent(object s, FileSystemEventArgs e) => changes.Writer.TryWrite(e.FullPath); - watcher.Changed += OnFsEvent; - watcher.Created += OnFsEvent; - watcher.Renamed += (s, e) => changes.Writer.TryWrite(e.FullPath); - watcher.EnableRaisingEvents = true; - - _out.Line($"\nwatching {Path.GetRelativePath(Environment.CurrentDirectory, baseDir)} for changes — Ctrl+C to stop", ConsoleColor.Cyan); + FileSystemWatcher NewWatcher(string dir) + { + var w = new FileSystemWatcher(dir, "*.sql") + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + w.Changed += OnFsEvent; + w.Created += OnFsEvent; + w.Renamed += (s, e) => changes.Writer.TryWrite(e.FullPath); + w.EnableRaisingEvents = true; + return w; + } + using var watcher = NewWatcher(testBaseFull); + // Watch the endpoint source tree too (unless it is the same/contained directory — the + // co-located layout — where the first watcher already covers it). + bool separateEndpointTree = endpointBaseFull is not null + && !testBaseFull.StartsWith(endpointBaseFull, StringComparison.Ordinal) + && !endpointBaseFull.StartsWith(testBaseFull, StringComparison.Ordinal); + using var endpointWatcher = separateEndpointTree ? NewWatcher(endpointBaseFull!) : null; + + var watching = Path.GetRelativePath(Environment.CurrentDirectory, testBaseFull) + + (endpointBaseFull is not null && endpointBaseFull != testBaseFull + ? $" + {Path.GetRelativePath(Environment.CurrentDirectory, endpointBaseFull)} (endpoints)" + : ""); + _out.Line($"\nwatching {watching} for changes — Ctrl+C to stop", ConsoleColor.Cyan); while (!stop.IsCancellationRequested) { - var changed = await NextChangeBatchAsync(changes.Reader, stop.Token); + var changed = (await NextChangeBatchAsync(changes.Reader, stop.Token))?.Distinct().ToList(); if (changed is null) break; // cancelled - // Debounced batch: changed test files rerun individually; any other .sql (fixture/profile - // include) forces a full rerun since its dependents are unknown. + // Debounced batch, classified per file: test file → rerun it alone; endpoint source → + // rebuild endpoints + full rerun; anything else under the test tree (fixture/profile + // include — dependents unknown) → full rerun; anything else → ignore. var testFiles = new List(); bool runAll = false; + bool rebuild = false; foreach (var path in changed) { - // Watcher paths are absolute; FilePattern is cwd-relative — compare in relative form. var rel = Path.GetRelativePath(Environment.CurrentDirectory, path).Replace('\\', '/'); - var pattern = _opt.FilePattern.Replace('\\', '/'); - if (pattern.StartsWith("./")) pattern = pattern[2..]; - if (Parser.IsPatternMatch(rel, pattern)) + if (MatchesCwdRelativePattern(rel, _opt.FilePattern)) { if (File.Exists(path)) testFiles.Add(Path.GetFullPath(path)); } - else + else if (endpointPattern is not null && rebuildEndpoints is not null + && MatchesCwdRelativePattern(rel, endpointPattern)) + { + rebuild = true; + } + else if (Path.GetFullPath(path).StartsWith(testBaseFull, StringComparison.Ordinal)) { runAll = true; } } - if (!runAll && testFiles.Count == 0) continue; // e.g. only deletions of test files + if (!rebuild && !runAll && testFiles.Count == 0) continue; // e.g. only deletions var trigger = Path.GetRelativePath(Environment.CurrentDirectory, changed[0]); _out.Line($"\n— {DateTime.Now:HH:mm:ss} change detected ({trigger}{(changed.Count > 1 ? $" +{changed.Count - 1}" : "")}) —", ConsoleColor.Cyan); + + if (rebuild) + { + // In-process endpoint rebuild: UseNpgsqlRest re-reads the sources, re-describes against + // the test database, and atomically swaps the endpoint registry. On failure the old + // endpoints stay live. The delta report makes a dropped endpoint (= a broken SQL file, + // skipped by ErrorMode=Skip) visible even when the NpgsqlRest log channel is muted. + try + { + var fresh = rebuildEndpoints!(); + ReportEndpointDelta(endpoints, fresh); + endpoints = fresh; + lookup = BuildEndpointLookup(fresh); + } + catch (Exception ex) + { + _out.LineAnsi($"endpoint rebuild failed: {ex.Message} — keeping the previous endpoints", AnsiFail); + _log?.LogError(ex, "endpoint rebuild failed"); + } + runAll = true; + } + await ExecuteDiscoveredFilesAsync(endpoints, lookup, only: runAll ? null : testFiles.Distinct().ToList(), stop.Token); _out.Line($"\nwatching — Ctrl+C to stop", ConsoleColor.Cyan); } @@ -411,9 +518,42 @@ void OnSignal(System.Runtime.InteropServices.PosixSignalContext ctx) } finally { - // Teardown must survive the SIGINT/SIGTERM that ended the loop → uncancellable token. - await TeardownAsync(CancellationToken.None); + // Normally a no-op: the signal handler already ran Teardown; covers non-signal exits. + await TeardownOnceAsync(); + } + } + + // A cwd-relative path against a cwd-relative glob (leading "./" tolerated on the pattern). + private static bool MatchesCwdRelativePattern(string relativePath, string pattern) + { + pattern = pattern.Replace('\\', '/'); + if (pattern.StartsWith("./")) pattern = pattern[2..]; + return Parser.IsPatternMatch(relativePath, pattern); + } + + // Rebuild delta: how many endpoints now, plus every added/removed "METHOD path" — a removed endpoint + // usually means its SQL file failed to parse/describe and was skipped. + private void ReportEndpointDelta(RoutineEndpoint[] before, RoutineEndpoint[] after) + { + static HashSet Keys(RoutineEndpoint[] eps) => + new(eps.Select(e => $"{e.Method} {e.Path}"), StringComparer.OrdinalIgnoreCase); + var beforeKeys = Keys(before); + var afterKeys = Keys(after); + var added = afterKeys.Except(beforeKeys).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var removed = beforeKeys.Except(afterKeys).OrderBy(k => k, StringComparer.Ordinal).ToList(); + + _out.Line($"endpoints rebuilt: {after.Length} (was {before.Length})", + removed.Count > 0 ? ConsoleColor.Yellow : ConsoleColor.Cyan); + foreach (var k in added) + { + _out.Line($" + {k}", ConsoleColor.Green); + } + foreach (var k in removed) + { + _out.Line($" - {k} (endpoint dropped — check its SQL file for errors)", ConsoleColor.Yellow); } + _log?.LogDebug("endpoints rebuilt: {After} (was {Before}); +{Added} -{Removed}", + after.Length, before.Length, added.Count, removed.Count); } // First change (blocking), then a quiet period so editor write bursts coalesce into one rerun. @@ -988,18 +1128,20 @@ private void ReportConsole(List results) if (fr.Assertions.Count == 0) { // The file ran but contained no recognizable assertion — surface it rather than count a pass. - _out.Line($"PASS {rel} (no assertions, {fr.ElapsedMs}ms)", ConsoleColor.Yellow); + _out.LineChip("PASS", AnsiPassChip, $" {rel} (no assertions, {fr.ElapsedMs}ms)", "\x1b[38;5;229m"); } else if (fr.Outcome == Outcome.Pass) { - _out.Line($"PASS {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)", ConsoleColor.Green); + _out.LineChip("PASS", AnsiPassChip, $" {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)"); if (_opt.DetailedReport) foreach (var a in fr.Assertions) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); } else { - var label = fr.Outcome == Outcome.Error ? "ERROR" : "FAIL "; - _out.LineAnsi($"{label} {rel} ({fr.ElapsedMs}ms)", AnsiFail); + // The label renders as Serilog's error chip (white on red); the rest of the line stays in + // the terminal's normal text color — same visual grammar as a `[... ERR]` log line. + var label = fr.Outcome == Outcome.Error ? "ERROR" : "FAIL"; + _out.LineChip(label, AnsiFailChip, $"{(fr.Outcome == Outcome.Error ? " " : " ")}{rel} ({fr.ElapsedMs}ms)"); foreach (var a in fr.Assertions) { if (a.Outcome == Outcome.Pass) @@ -1020,7 +1162,7 @@ private void ReportConsole(List results) } var summary = $"\n{passed} passed, {failed} failed, {errored} error(s) — {totalAssertions} assertion{(totalAssertions == 1 ? "" : "s")} in {results.Count} file{(results.Count == 1 ? "" : "s")}"; - if (failed + errored == 0) _out.Line(summary, ConsoleColor.Green); + if (failed + errored == 0) _out.LineAnsi(summary, AnsiOk); else _out.LineAnsi(summary, AnsiFail); } diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 935b527f..5b0ebb6a 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -73,9 +73,10 @@ public class TestRunnerOptions /// /// Watch mode (interactive/dev-only; CLI: --watch): run everything once, then re-run on file - /// changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched - /// tree re-runs everything (an included fixture's dependents are unknown). Endpoint sources are not - /// watched — restart to rebuild endpoints. Teardown runs once, on exit; graceful exit code is 0. + /// changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource + /// FilePattern) rebuilds the endpoints in-process (delta reported) and re-runs everything; any other + /// changed .sql under the test tree re-runs everything (an included fixture's dependents are unknown). + /// Teardown runs once, on exit; graceful exit code is 0. /// public bool Watch { get; set; } = false; diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 1b00ba09..822646c8 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1494,9 +1494,11 @@ "AllowEmpty": false, // // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file - // changes until Ctrl+C. A changed test file re-runs alone; any other changed .sql under the watched - // tree (e.g. an included fixture) re-runs everything. Endpoint sources are not watched — restart to - // rebuild endpoints. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating). + // changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource + // FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a + // broken file cannot kill the session) and re-runs everything; any other changed .sql under the test + // tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop + // exits 0 (not for CI gating). // "Watch": false, // diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 4a5be38d..c366b271 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -158,6 +158,8 @@ Run-once steps around the whole test session. **`Setup` runs before endpoint dis `Sql`/`SqlFile` steps run on the test connection by default, or on any named `ConnectionStrings` entry via a per-step `"ConnectionName"` — which enables maintenance operations like `create database` without the runner ever issuing DDL on its own. +`Teardown` is guaranteed beyond the happy path: from Setup onward the runner intercepts **SIGINT (Ctrl+C) and SIGTERM** (e.g. `docker stop`) and runs Teardown **synchronously in the signal handler** — before the process can be torn down by an impatient parent (`bun run`/`npm run` forward Ctrl+C and may kill their children immediately; waiting for the run loop to unwind would lose that race). A second Ctrl+C force-quits. A **process-exit hook** additionally covers hard exits — e.g. a broken endpoint SQL file under `SqlFileSource.ErrorMode: Exit` calls `Environment.Exit(1)`, which previously leaked the just-created test database; the exit code is unchanged, but Teardown now runs first. All paths funnel into a run-once Teardown. (A parent that SIGKILLs instantly remains unsurvivable — that is what a leading `drop database if exists …` on a static name, or a periodic sweep, is for.) + Steps can be defined once in the **`Steps` registry** (name → step, like `ConnectionStrings` or `CacheOptions.Profiles`) and referenced by name; `Setup`/`Teardown` arrays accept names and inline objects mixed. Referencing an unknown name is a configuration error (exit 3). ```jsonc @@ -233,11 +235,13 @@ FAIL tests/get_users.test.sql (49ms) 18 passed, 1 failed, 0 error(s) — 19 assertions in 9 files ``` -Failures show the assertion name, `file:line`, and the failing statement. `DetailedReport: true` additionally lists passed assertions (`✓`), full failing SQL, and captured `raise notice` output for passing tests (notices always show under failing tests). This shapes the console report only — it is distinct from raising the `NpgsqlRestTest` *log* level, which controls diagnostics (see Logging below). A file with no recognizable assertions is flagged (yellow) rather than silently counted. Colors are disabled automatically when output is redirected. +Failures show the assertion name, `file:line`, and the failing statement. `DetailedReport: true` additionally lists passed assertions (`✓`), full failing SQL, and captured `raise notice` output for passing tests (notices always show under failing tests). This shapes the console report only — it is distinct from raising the `NpgsqlRestTest` *log* level, which controls diagnostics (see Logging below). A file with no recognizable assertions is flagged rather than silently counted. + +The report's colors are **matched to Serilog's Code console theme**, so the test report and the log lines around it read as one output: the `FAIL`/`ERROR` labels render as the *byte-identical chip* the theme uses for its `ERR`/`FTL` level (red text on a dark-grey block), `PASS` uses the same chip grammar in the mirror green, the rest of each line stays in the terminal's normal text color, and all failure text uses the theme's error red — never the 16-color red that renders orange in some terminals. Colors are disabled automatically when output is redirected (piped/CI logs stay plain). **JUnit XML** (`JUnitOutput: "path.xml"`): one `` per assertion (name = the assertion message, classname = the file), ``/`` with message and `file:line`, captured notices in ``, files without assertions marked `` — works with any CI dashboard. -**Exit codes:** `0` all passed · `1` at least one failure · `2` at least one error (SQL error, timeout, unsupported endpoint) · `3` setup/configuration error · `4` no test files found (`AllowEmpty: true` turns this into `0`). +**Exit codes:** `0` all passed · `1` at least one failure · `2` at least one error (SQL error, timeout, unsupported endpoint, an interrupted run) · `3` setup/configuration error · `4` no test files found (`AllowEmpty: true` turns this into `0`). ### Logging @@ -325,7 +329,13 @@ Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) ar npgsqlrest ./config.json ./test-config.json --test --watch ``` -`Setup` runs once, then everything runs once, then the `FilePattern` base directory is watched recursively for `*.sql` changes (debounced): a changed **test file** re-runs alone (the `Filter` still applies); any **other** changed `.sql` — an included fixture or profile, whose dependents are unknown — re-runs everything. `Teardown` runs **once, on exit**: Ctrl+C (SIGINT) and SIGTERM (e.g. `docker stop`) are intercepted so the test database is still dropped cleanly. Interactive/dev-only: a graceful stop exits `0` regardless of test outcomes — watch is not for CI gating. Endpoint *sources* are not watched (endpoints are built once) — restart to rebuild them. +`Setup` runs once, then everything runs once, then the test tree — **and, when the SQL file source is enabled, the endpoint source tree** — is watched recursively for `*.sql` changes (debounced). Changes are classified per file: + +- a changed **test file** re-runs alone (the `Filter` still applies); +- a changed **endpoint file** (matching `SqlFileSource.FilePattern`) triggers an **in-process endpoint rebuild** — the sources are re-read and re-described against the test database, and the endpoint registry is swapped atomically — followed by a full rerun. After each rebuild the runner prints the **endpoint delta** (`+ POST /api/new`, `- GET /api/x (endpoint dropped — check its SQL file for errors)`), so breaking an endpoint file mid-session is visible immediately: the endpoint drops out, its tests fail with 404 warnings, and fixing the file brings it right back — no restart. (To make this safe, watch mode forces `SqlFileSource.ErrorMode` from `Exit` to `Skip` — a broken file must not kill the watch session; non-watch `--test` keeps `Exit` for CI. A rebuild that fails entirely keeps the previous endpoints live.) +- any **other** changed `.sql` under the test tree — an included fixture or profile, whose dependents are unknown — re-runs everything. + +`Teardown` runs **once, on exit** — synchronously inside the SIGINT/SIGTERM handler (see the Setup and Teardown section), so the test database is dropped even when the watch process is stopped through a wrapper like `bun run`; a second Ctrl+C force-quits. Interactive/dev-only: a graceful stop exits `0` regardless of test outcomes — watch is not for CI gating. Database-routine sources have no files to watch — restart to pick up catalog changes. ### Project layout From 94667a9275a06eda04416caa9107ea382948baf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 20:19:24 +0200 Subject: [PATCH 06/14] fix(test-runner): one red style, one green style in the console report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnsiFail is now the full Serilog Code ERR chip style (red-197 on grey-238) and is the ONLY red in the report — FAIL/ERROR labels and every failure line (assertion details, messages, failing summary, coverage gate, setup/config errors) share the exact same escape sequence, so they can never render differently. AnsiOk is the mirror (green-47 on grey-238) for the PASS label and the all-green summary. Indentation and blank lines are written outside the styled block so the grey strip starts at the text. --- NpgsqlRestClient/Testing/TestRunner.cs | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 35ccafbd..9388c0f8 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -16,16 +16,17 @@ namespace NpgsqlRestClient.Testing; /// public sealed class TestRunner { - // Report styling, matched to Serilog's Code theme: the FAIL/ERROR label renders as the byte-identical - // CHIP the Code theme uses for its ERR/FTL level — red-197 text on a dark-grey-238 background (NOT the - // white-on-red-196 chip, which is the Literate theme's). PASS uses the same chip grammar with green-47, - // the exact color-cube mirror of red-197 (#ff005f ↔ #00ff5f). ALL failure text uses the red-197 - // foreground (never 16-color ConsoleColor.Red, which renders orange in some themes). Escapes are - // emitted only on a real terminal (see Out). - private const string AnsiFailChip = "\x1b[38;5;197m\x1b[48;5;238m"; - private const string AnsiPassChip = "\x1b[38;5;47m\x1b[48;5;238m"; - public const string AnsiFail = "\x1b[38;5;197m"; - private const string AnsiOk = "\x1b[38;5;47m"; + // Report styling, matched to Serilog's Code theme. There is exactly ONE red style and ONE green style: + // AnsiFail is byte-identical to the chip the Code theme uses for its ERR/FTL level — red-197 text on a + // dark-grey-238 background (NOT white-on-red-196, which is the Literate theme's chip) — and it is used + // for the FAIL/ERROR labels AND all failure text, so they can never look different. AnsiOk is the same + // grammar with green-47, the exact color-cube mirror of red-197 (#ff005f ↔ #00ff5f), used for the PASS + // label and the all-green summary. Never 16-color ConsoleColor.Red (renders orange in some themes). + // Indentation is written OUTSIDE the style so the grey block starts at the text. Escapes are emitted + // only on a real terminal (see Out). + public const string AnsiFail = "\x1b[38;5;197m\x1b[48;5;238m"; + private const string AnsiOk = "\x1b[38;5;47m\x1b[48;5;238m"; + private const string AnsiPlain = "\x1b[0m"; public const int ExitPass = 0; public const int ExitFailures = 1; @@ -349,7 +350,7 @@ private int ReportCoverage(RoutineEndpoint[] endpoints, int exit) var line = $"\nendpoint coverage: {covered}/{testable.Count} ({pct}%)" + (untestable > 0 ? $" — {untestable} endpoint(s) excluded (not testable in test mode)" : ""); - if (gateFailed) _out.LineAnsi(line, AnsiFail); + if (gateFailed) { _out.NL(); _out.LineAnsi(line.TrimStart('\n'), AnsiFail); } else _out.Line(line, untested.Count == 0 ? ConsoleColor.Green : ConsoleColor.Yellow); foreach (var ep in untested) @@ -360,7 +361,7 @@ private int ReportCoverage(RoutineEndpoint[] endpoints, int exit) if (gateFailed) { - _out.LineAnsi($" coverage {pct}% is below the CoverageThreshold ({_opt.CoverageThreshold}%)", AnsiFail); + _out.LineChip(" ", AnsiPlain, $"coverage {pct}% is below the CoverageThreshold ({_opt.CoverageThreshold}%)", AnsiFail); if (exit == ExitPass) exit = ExitErrors; } return exit; @@ -1128,11 +1129,11 @@ private void ReportConsole(List results) if (fr.Assertions.Count == 0) { // The file ran but contained no recognizable assertion — surface it rather than count a pass. - _out.LineChip("PASS", AnsiPassChip, $" {rel} (no assertions, {fr.ElapsedMs}ms)", "\x1b[38;5;229m"); + _out.LineChip("PASS", AnsiOk, $" {rel} (no assertions, {fr.ElapsedMs}ms)", "\x1b[38;5;229m"); } else if (fr.Outcome == Outcome.Pass) { - _out.LineChip("PASS", AnsiPassChip, $" {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)"); + _out.LineChip("PASS", AnsiOk, $" {rel} ({aPass} assertion{(aPass == 1 ? "" : "s")}, {fr.ElapsedMs}ms)"); if (_opt.DetailedReport) foreach (var a in fr.Assertions) _out.Line($" ✓ {a.Name}", ConsoleColor.DarkGray); } @@ -1141,7 +1142,7 @@ private void ReportConsole(List results) // The label renders as Serilog's error chip (white on red); the rest of the line stays in // the terminal's normal text color — same visual grammar as a `[... ERR]` log line. var label = fr.Outcome == Outcome.Error ? "ERROR" : "FAIL"; - _out.LineChip(label, AnsiFailChip, $"{(fr.Outcome == Outcome.Error ? " " : " ")}{rel} ({fr.ElapsedMs}ms)"); + _out.LineChip(label, AnsiFail, $"{(fr.Outcome == Outcome.Error ? " " : " ")}{rel} ({fr.ElapsedMs}ms)"); foreach (var a in fr.Assertions) { if (a.Outcome == Outcome.Pass) @@ -1151,8 +1152,8 @@ private void ReportConsole(List results) } var locFile = a.SourceFile is null ? rel : Path.GetRelativePath(Environment.CurrentDirectory, a.SourceFile); var loc = a.Line is null ? "" : $" [{locFile}:{a.Line}]"; - _out.LineAnsi($" ✗ {a.Name}{loc}", AnsiFail); - if (!string.IsNullOrWhiteSpace(a.Message) && a.Message != a.Name) _out.LineAnsi($" {a.Message}", AnsiFail); + _out.LineChip(" ", AnsiPlain, $"✗ {a.Name}{loc}", AnsiFail); + if (!string.IsNullOrWhiteSpace(a.Message) && a.Message != a.Name) _out.LineChip(" ", AnsiPlain, a.Message, AnsiFail); WriteFailingSql(a.Sql); } } @@ -1161,9 +1162,9 @@ private void ReportConsole(List results) foreach (var n in fr.Notices) _out.Line($" notice: {n}", ConsoleColor.DarkGray); } - var summary = $"\n{passed} passed, {failed} failed, {errored} error(s) — {totalAssertions} assertion{(totalAssertions == 1 ? "" : "s")} in {results.Count} file{(results.Count == 1 ? "" : "s")}"; - if (failed + errored == 0) _out.LineAnsi(summary, AnsiOk); - else _out.LineAnsi(summary, AnsiFail); + var summary = $"{passed} passed, {failed} failed, {errored} error(s) — {totalAssertions} assertion{(totalAssertions == 1 ? "" : "s")} in {results.Count} file{(results.Count == 1 ? "" : "s")}"; + _out.NL(); + _out.LineAnsi(summary, failed + errored == 0 ? AnsiOk : AnsiFail); } private static void WriteJUnit(List results, string path) From 19ecbaed873740a1fb26814539748945561fba3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 20:39:21 +0200 Subject: [PATCH 07/14] feat(test-runner): endpoint coverage on by default for full runs (tri-state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestRunner.Coverage is now tri-state: - null (the new default): report the endpoint-coverage summary after FULL runs — it costs one line and surfaces untested endpoints passively — but stay quiet when the run is narrowed by Filter/Tag (a deliberately partial run would just nag about endpoints its subset never touches); - true: always report, including narrowed runs; - false: never report. CoverageThreshold always reports and gates when set, regardless of Coverage or narrowing — it is an explicit ask for enforcement. Config synced (appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs — incl. previously missing schema descriptions for Coverage/CoverageThreshold). Changelog updated. Full suite green (2364); all five behaviors verified live (default-on full run, narrowed suppression, forced on, forced off, threshold reporting). --- NpgsqlRestClient/Builder.cs | 6 +++++- NpgsqlRestClient/ConfigDefaults.cs | 2 +- NpgsqlRestClient/ConfigSchemaGenerator.cs | 2 ++ NpgsqlRestClient/ConfigTemplate.cs | 8 +++++--- NpgsqlRestClient/Testing/TestRunner.cs | 9 ++++++++- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 11 +++++++---- NpgsqlRestClient/appsettings.json | 8 +++++--- changelog/v3.19.0.md | 8 ++++---- 8 files changed, 37 insertions(+), 17 deletions(-) diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 74b4c395..a2590046 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3081,7 +3081,11 @@ public TestRunnerOptions BuildTestRunnerOptions() opt.AllowEmpty = _config.GetConfigBool("AllowEmpty", section, false); opt.Watch = _config.GetConfigBool("Watch", section, false); opt.CoverageThreshold = _config.GetConfigInt("CoverageThreshold", section); - opt.Coverage = _config.GetConfigBool("Coverage", section, false) || opt.CoverageThreshold is not null; + // Tri-state: absent/empty => null (report after full runs only); explicit true/false honored. + if (string.IsNullOrEmpty(section.GetSection("Coverage").Value) is false) + { + opt.Coverage = _config.GetConfigBool("Coverage", section, false); + } var rt = section.GetSection("ResponseTempTable"); if (rt.Exists()) diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 57439532..8ccc6117 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1113,7 +1113,7 @@ private static JsonObject GetTestRunnerDefaults() ["DetailedReport"] = false, ["AllowEmpty"] = false, ["Watch"] = false, - ["Coverage"] = false, + ["Coverage"] = null, ["CoverageThreshold"] = null, ["LoggerName"] = "NpgsqlRestTest", ["ResponseTempTable"] = new JsonObject diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 512469ab..163cccdb 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -260,6 +260,8 @@ public static partial class ConfigSchemaGenerator ["TestRunner:DetailedReport"] = "Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show captured `raise notice` output for passing tests too.\nThis shapes the report only — for diagnostic logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName).", ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", ["TestRunner:Watch"] = "Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file changes until Ctrl+C.\nA changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a broken file cannot kill the session) and re-runs everything; any other changed .sql under the test tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating).", + ["TestRunner:Coverage"] = "Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded from the ratio and counted separately).\nTri-state: null (default) reports after FULL runs but stays quiet when the run is narrowed by Filter/Tag; true always reports; false never.", + ["TestRunner:CoverageThreshold"] = "Fail an otherwise-passing run with exit 2 when endpoint coverage is below this percentage (0-100) — CI gating for \"every endpoint has a test\". Always reports coverage when set, regardless of the Coverage setting or run narrowing. Null = no gate.", ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 523c6848..4ec7f671 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1513,10 +1513,12 @@ public static partial class ConfigSchemaGenerator // // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded - // from the ratio and counted separately). CoverageThreshold (0-100, implies Coverage) fails an - // otherwise-passing run with exit 2 when coverage is below it — CI gating for "every endpoint has a test". + // from the ratio and counted separately). Tri-state: null (default) reports after FULL runs but stays + // quiet when the run is narrowed by Filter/Tag (a deliberately partial run would just nag); true always + // reports; false never. CoverageThreshold (0-100) always reports and fails an otherwise-passing run + // with exit 2 when coverage is below it — CI gating for "every endpoint has a test". // - "Coverage": false, + "Coverage": null, "CoverageThreshold": null, // // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 9388c0f8..0c56c075 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -318,7 +318,14 @@ await Parallel.ForEachAsync( int exit = ordered.Any(r => r.Outcome == Outcome.Error) ? ExitErrors : ordered.Any(r => r.Outcome == Outcome.Fail) ? ExitFailures : ExitPass; - if (_opt.Coverage && only is null) + // Coverage reports on complete runs (never on watch partial reruns). Default (Coverage=null): + // report unless the run was narrowed by Filter/Tag — a deliberately partial run would just nag + // about endpoints its subset never touches. Coverage=true always reports, false never; a set + // CoverageThreshold always reports (and gates) — that's an explicit ask for enforcement. + bool narrowed = !string.IsNullOrWhiteSpace(_opt.Filter) || _opt.Tags.Count > 0 || _opt.ExcludeTags.Count > 0; + bool showCoverage = only is null + && (_opt.CoverageThreshold is not null || _opt.Coverage == true || (_opt.Coverage is null && !narrowed)); + if (showCoverage) { exit = ReportCoverage(endpoints, exit); } diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 5b0ebb6a..014dbd3f 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -24,11 +24,14 @@ public class TestRunnerOptions public List ExcludeTags { get; set; } = []; /// - /// Emit an endpoint-coverage summary after the run: exercised N of M testable endpoints plus the list - /// of untested ones. Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are - /// excluded from the ratio and reported separately. + /// Endpoint-coverage summary after the run: exercised N of M testable endpoints plus the list of + /// untested ones. Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are + /// excluded from the ratio and reported separately. Tri-state: null (default) = report after FULL runs + /// but stay quiet when the run is narrowed by / (a deliberately + /// partial run would just nag); true = always report; false = never. A set + /// always reports and gates. /// - public bool Coverage { get; set; } = false; + public bool? Coverage { get; set; } = null; /// /// Fail an otherwise-passing run (exit 2) when endpoint coverage is below this percentage (0-100), for diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 822646c8..5a8cccba 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1504,10 +1504,12 @@ // // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded - // from the ratio and counted separately). CoverageThreshold (0-100, implies Coverage) fails an - // otherwise-passing run with exit 2 when coverage is below it — CI gating for "every endpoint has a test". + // from the ratio and counted separately). Tri-state: null (default) reports after FULL runs but stays + // quiet when the run is narrowed by Filter/Tag (a deliberately partial run would just nag); true always + // reports; false never. CoverageThreshold (0-100) always reports and fails an otherwise-passing run + // with exit 2 when coverage is below it — CI gating for "every endpoint has a test". // - "Coverage": false, + "Coverage": null, "CoverageThreshold": null, // // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index c366b271..2d703fc1 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -273,8 +273,8 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests "AllowEmpty": false, // exit 0 instead of 4 when no tests are found "Watch": false, // watch mode (CLI: --watch): re-run on file changes until Ctrl+C; teardown on exit - "Coverage": false, // endpoint-coverage summary: exercised N/M testable + the untested list - "CoverageThreshold": null, // 0-100 (implies Coverage): fail an otherwise-passing run (exit 2) below it + "Coverage": null, // coverage summary: null (default) = on for full runs, quiet when narrowed; true/false = always/never + "CoverageThreshold": null, // 0-100: always report + fail an otherwise-passing run (exit 2) below it "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) "ResponseTempTable": { "Name": "_response", // table name when a file has ONE HTTP block @@ -312,7 +312,7 @@ npgsqlrest ... --test --testrunner:tag=smoke --testrunner:excludetag=slow Since includes behave as if pasted, tags travel through a **shared profile** too: a profile file carrying `-- @tag isolation, slow` next to its `-- @setup`/`-- @connection` annotations tags *every test that attaches it* — e.g. all clone-isolated tests are automatically `slow`, so the everyday dev loop is just `--testrunner:excludetag=slow`, with zero per-file bookkeeping. -**Endpoint coverage** (`Coverage: true`, or just set a threshold) is something only an integrated runner can offer: the runner knows the **entire API surface** it built *and* records every endpoint the tests actually invoked, so after the run it reports the API-level analogue of code coverage — including the exact endpoints no test touches: +**Endpoint coverage** is something only an integrated runner can offer: the runner knows the **entire API surface** it built *and* records every endpoint the tests actually invoked, so after the run it reports the API-level analogue of code coverage — including the exact endpoints no test touches. It is **on by default for full runs** (it costs one line); a run narrowed by `Filter`/`Tag` stays quiet — a deliberately partial run would just nag — unless `Coverage: true` forces it, and `Coverage: false` silences it entirely: ``` 19 passed, 0 failed, 0 error(s) — 19 assertions in 9 files @@ -321,7 +321,7 @@ endpoint coverage: 1/2 (50%) untested: GET /api/get-users ``` -Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are excluded from the ratio and counted separately, so the number is honest. **`CoverageThreshold`** (0–100, implies `Coverage`) turns it into a CI gate: an otherwise-passing run below the threshold exits `2` — set it to `100` and forgetting to write a test for a new endpoint fails the build, naming the endpoint. "Covered" means *invoked at least once by a test* — execution, not assertion depth (the same semantics as code coverage). +Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are excluded from the ratio and counted separately, so the number is honest. **`CoverageThreshold`** (0–100) turns it into a CI gate — it always reports, regardless of the `Coverage` setting or run narrowing: an otherwise-passing run below the threshold exits `2` — set it to `100` and forgetting to write a test for a new endpoint fails the build, naming the endpoint. "Covered" means *invoked at least once by a test* — execution, not assertion depth (the same semantics as code coverage). **Watch mode** (`--watch`, or `TestRunner:Watch`) keeps the process alive and re-runs on change — and because the endpoint middleware is built once at startup, re-runs are near-instant: From 6cd715090508a4b2dca923ad8970b3d042b8e18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Thu, 2 Jul 2026 22:06:52 +0200 Subject: [PATCH 08/14] feat(sql-file-source): named parameters (:name) in SQL files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL file endpoints can use named placeholders instead of positional $1..$N: select * from users where email = :email and age > :age; The placeholder IS the parameter: :email becomes API parameter "email" through the same NameConverter routine parameters use (:user_id -> userId), so @param annotations that existed only to NAME positional parameters become unnecessary. Claim mappings (ParameterNameClaimsMapping, @user_parameters) hook up by the placeholder's own name with zero annotations. Mechanics: statements are rewritten to native $N right after parsing — PostgreSQL never sees :name, so Describe, type inference, and runtime behavior are identical to positional files. The same name (case-insensitive, first spelling wins) maps to ONE parameter file-wide, including across statements in multi-command files. Token-aware rewriter: strings ('', ""), comments (nested /* */), and dollar-quoted bodies untouched; :: casts, := calls, and numeric slice bounds (a[1:3]) never match (variable slice bounds need a space: a[1 : n]). Mixing $N and :name in one file is rejected (ErrorMode semantics). Annotations match named parameters by name: `@param email default null` works as-is; `@param :email citext` is a Describe type hint; the new `@param email type is citext` form retypes WITHOUT rename (core ParameterHandler, delegates to the rename path as rename-to-same-name; also tolerates a leading ':' on the parameter reference). Typed-annotation detection for HTTP-client/composite types accepts the named form. `?` (JDBC style) considered and rejected: ?, ?|, ?& and @? are PostgreSQL's own jsonb/geometric operators — no rewriter can safely claim `?`; same reason the PG JDBC driver requires ?? escapes. Documented in the changelog. Tests: 22 rewriter unit tests + 7 end-to-end endpoint tests (auto-naming, repeated placeholder from one value, required-param matching, name-matched defaults, `type is` retype producing an unquoted JSON int, claim mapping by placeholder name, mixed-style rejection, multi-command cross-statement sharing). Full suite green (2393). Example 20's login.sql converted to :email/:password live (docs repo, committed separately). Changelog section + plugin README updated. --- .../CommentParsers/ParameterHandler.cs | 22 ++ .../NamedParamEndpointTests.cs | 127 +++++++++ .../ParserTests/NamedParamRewriteTests.cs | 192 +++++++++++++ changelog/v3.19.0.md | 34 ++- plugins/NpgsqlRest.SqlFileSource/README.md | 12 +- .../NpgsqlRest.SqlFileSource/SqlFileParser.cs | 268 ++++++++++++++++-- .../NpgsqlRest.SqlFileSource/SqlFileSource.cs | 83 ++++-- 7 files changed, 696 insertions(+), 42 deletions(-) create mode 100644 NpgsqlRestTests/SqlFileSourceTests/AdvancedFeatureTests/NamedParamEndpointTests.cs create mode 100644 NpgsqlRestTests/SqlFileSourceTests/ParserTests/NamedParamRewriteTests.cs diff --git a/NpgsqlRest/Defaults/CommentParsers/ParameterHandler.cs b/NpgsqlRest/Defaults/CommentParsers/ParameterHandler.cs index 9a35b28b..8cfc299d 100644 --- a/NpgsqlRest/Defaults/CommentParsers/ParameterHandler.cs +++ b/NpgsqlRest/Defaults/CommentParsers/ParameterHandler.cs @@ -36,6 +36,14 @@ private static void HandleParameter( int len, string description) { + // Tolerate the :name placeholder spelling from named-parameter SQL files: "@param :email ..." + // targets the parameter whose ActualName is "email". + if (len >= 2 && words[1].Length > 1 && words[1][0] == ':') + { + words[1] = words[1][1..]; + wordsLower[1] = wordsLower[1][1..]; + } + // param param_name1 is hash of param_name2 if (len >= 6 && StrEquals(wordsLower[2], "is") && StrEquals(wordsLower[3], "hash") && StrEquals(wordsLower[4], "of")) { @@ -128,6 +136,20 @@ private static void HandleParameter( return; } + // param [name] type is [type] [default ...] — retype (and optionally default) WITHOUT rename. + // Useful for named-placeholder (:name) SQL file parameters, where the name comes from the + // placeholder and only the type needs overriding. Delegated to the rename path as a + // rename-to-same-name: [param, X, type, is, Y, ...] → [param, X, is, X, Y, ...]. + if (len >= 5 && StrEquals(wordsLower[2], "type") && StrEquals(wordsLower[3], "is")) + { + var w = (string[])words.Clone(); + var wl = (string[])wordsLower.Clone(); + w[2] = "is"; wl[2] = "is"; + w[3] = words[1]; wl[3] = wordsLower[1]; + HandleParameterRename(routine, wl, w, len, description); + return; + } + // Rename / retype forms: // param [original] is [new_name] — len >= 4, wordsLower[2] == "is" // param [original] is [new_name] [type] — len >= 5, wordsLower[2] == "is" diff --git a/NpgsqlRestTests/SqlFileSourceTests/AdvancedFeatureTests/NamedParamEndpointTests.cs b/NpgsqlRestTests/SqlFileSourceTests/AdvancedFeatureTests/NamedParamEndpointTests.cs new file mode 100644 index 00000000..839ba325 --- /dev/null +++ b/NpgsqlRestTests/SqlFileSourceTests/AdvancedFeatureTests/NamedParamEndpointTests.cs @@ -0,0 +1,127 @@ +namespace NpgsqlRestTests.SqlFileSourceTests; + +public static partial class SqlFiles +{ + public static void NamedParamEndpointTests() + { + // Named placeholders: API names come from the placeholders (through the camelCase NameConverter), + // no @param annotations needed; a repeated name maps to the SAME parameter. + File.WriteAllText(Path.Combine(Dir, "named_echo.sql"), """ + /* + HTTP GET + */ + select :first_name || ' ' || :last_name || ' (' || :first_name || ')' as greeting; + """); + + // Optional named parameter via the existing name-matching default annotation. + File.WriteAllText(Path.Combine(Dir, "named_default.sql"), """ + -- @param label default null + select coalesce(:label, 'none') as v; + """); + + // Retype WITHOUT rename via the new `type is` form (both the Describe hint and endpoint retype). + File.WriteAllText(Path.Combine(Dir, "named_type_is.sql"), """ + -- @param val type is int + select :val as v; + """); + + // Claim-mapped user parameter — the placeholder name itself (_user_id) matches + // ParameterNameClaimsMapping, so the claim hookup needs no annotation at all. + File.WriteAllText(Path.Combine(Dir, "named_user_param.sql"), """ + -- @authorize + -- @user_parameters + select :_user_id as user_id; + """); + + // Mixing positional and named placeholders is rejected (ErrorMode=Skip → no endpoint). + File.WriteAllText(Path.Combine(Dir, "named_mixed_invalid.sql"), """ + select $1 as a, :b as b; + """); + + // Multi-command file: :id is shared ACROSS statements (one API parameter), :offset appears + // only in the second statement — still no annotations needed. + File.WriteAllText(Path.Combine(Dir, "named_multi.sql"), """ + -- HTTP POST + -- @result first + select :id::int as v; + -- @result second + select :id::int + :offset::int as v; + """); + } +} + +[Collection("SqlFileSourceFixture")] +public class NamedParamEndpointTests(SqlFileSourceTestFixture test) +{ + [Fact] + public async Task NamedParams_AutoNamed_CamelCased_And_RepeatedNameShared() + { + using var response = await test.Client.GetAsync("/api/named-echo?firstName=Ada&lastName=Lovelace"); + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + content.Should().Be("[\"Ada Lovelace (Ada)\"]"); + } + + [Fact] + public async Task NamedParams_MissingRequiredParam_NotFound() + { + // lastName missing → endpoint parameter set doesn't match → 404 + using var response = await test.Client.GetAsync("/api/named-echo?firstName=Ada"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task NamedParams_DefaultAnnotation_MatchesByPlaceholderName() + { + using var response = await test.Client.GetAsync("/api/named-default"); + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + content.Should().Be("[\"none\"]"); + + using var response2 = await test.Client.GetAsync("/api/named-default?label=tagged"); + var content2 = await response2.Content.ReadAsStringAsync(); + response2.StatusCode.Should().Be(HttpStatusCode.OK); + content2.Should().Be("[\"tagged\"]"); + } + + [Fact] + public async Task NamedParams_TypeIsForm_RetypesWithoutRename() + { + using var response = await test.Client.GetAsync("/api/named-type-is?val=7"); + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + // int type → rendered unquoted in JSON + content.Should().Be("[7]"); + } + + [Fact] + public async Task NamedParams_ClaimMappedByPlaceholderName_NoAnnotationNeeded() + { + using var client = test.CreateClient(); + await client.GetAsync("/login"); + + using var response = await client.GetAsync("/api/named-user-param"); + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + // name_identifier claim = "user123" + content.Should().Be("[\"user123\"]"); + } + + [Fact] + public async Task MixedPositionalAndNamed_FileSkipped_NoEndpoint() + { + using var response = await test.Client.GetAsync("/api/named-mixed-invalid?a=1&b=2"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task NamedParams_SharedAcrossStatements_OneApiParameter() + { + using var body = new StringContent("{\"id\": 10, \"offset\": 5}", Encoding.UTF8, "application/json"); + using var response = await test.Client.PostAsync("/api/named-multi", body); + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, $"Response: {content}"); + // one `id` value feeds both statements; `offset` only the second + content.Should().Be("""{"first":[10],"second":[15]}"""); + } +} diff --git a/NpgsqlRestTests/SqlFileSourceTests/ParserTests/NamedParamRewriteTests.cs b/NpgsqlRestTests/SqlFileSourceTests/ParserTests/NamedParamRewriteTests.cs new file mode 100644 index 00000000..4a6eeaa1 --- /dev/null +++ b/NpgsqlRestTests/SqlFileSourceTests/ParserTests/NamedParamRewriteTests.cs @@ -0,0 +1,192 @@ +using NpgsqlRest.SqlFileSource; + +namespace NpgsqlRestTests.SqlFileSourceTests; + +public class NamedParamRewriteTests +{ + private static (List Statements, NamedParamRewriteResult Result) Rewrite(string sql) + { + var parsed = SqlFileParser.Parse(sql); + var result = SqlFileParser.RewriteNamedParameters(parsed.Statements); + return (parsed.Statements, result); + } + + [Fact] + public void SingleNamedParam_RewrittenToPositional() + { + var (statements, result) = Rewrite("select * from users where id = :id"); + statements[0].Should().Be("select * from users where id = $1"); + result.Names.Should().Equal("id"); + result.HasNamed.Should().BeTrue(); + result.HasPositional.Should().BeFalse(); + } + + [Fact] + public void MultipleNamedParams_OrdinalsInFirstAppearanceOrder() + { + var (statements, result) = Rewrite("select :b, :a, :c"); + statements[0].Should().Be("select $1, $2, $3"); + result.Names.Should().Equal("b", "a", "c"); + } + + [Fact] + public void RepeatedName_MapsToSameOrdinal() + { + var (statements, result) = Rewrite("select :id where :id > 0 and :other < :id"); + statements[0].Should().Be("select $1 where $1 > 0 and $2 < $1"); + result.Names.Should().Equal("id", "other"); + } + + [Fact] + public void RepeatedName_CaseInsensitive_KeepsFirstSpelling() + { + var (statements, result) = Rewrite("select :UserId, :userid"); + statements[0].Should().Be("select $1, $1"); + result.Names.Should().Equal("UserId"); + } + + [Fact] + public void SameName_AcrossStatements_SharesParameter() + { + var (statements, result) = Rewrite("select :id;\nupdate t set x = :val where id = :id;"); + statements[0].Should().Be("select $1"); + statements[1].Should().Contain("set x = $2 where id = $1"); + result.Names.Should().Equal("id", "val"); + } + + [Fact] + public void DoubleColonCast_NotAParameter() + { + var (statements, result) = Rewrite("select '1'::int, :id::text"); + statements[0].Should().Be("select '1'::int, $1::text"); + result.Names.Should().Equal("id"); + } + + [Fact] + public void CastOnly_NoNamedParams() + { + var (statements, result) = Rewrite("select a::int, b::text from t"); + statements[0].Should().Be("select a::int, b::text from t"); + result.HasNamed.Should().BeFalse(); + } + + [Fact] + public void ColonEquals_NotAParameter() + { + var (statements, result) = Rewrite("select f(a := 1)"); + statements[0].Should().Be("select f(a := 1)"); + result.HasNamed.Should().BeFalse(); + } + + [Fact] + public void NumericSliceBound_NotAParameter() + { + var (statements, result) = Rewrite("select arr[1:3] from t"); + statements[0].Should().Be("select arr[1:3] from t"); + result.HasNamed.Should().BeFalse(); + } + + [Fact] + public void InsideSingleQuotedString_Untouched() + { + var (statements, result) = Rewrite("select ':notaparam', :real"); + statements[0].Should().Be("select ':notaparam', $1"); + result.Names.Should().Equal("real"); + } + + [Fact] + public void InsideEscapedQuotes_Untouched() + { + var (statements, result) = Rewrite("select 'it''s :x here', :y"); + statements[0].Should().Be("select 'it''s :x here', $1"); + result.Names.Should().Equal("y"); + } + + [Fact] + public void InsideDoubleQuotedIdentifier_Untouched() + { + var (statements, result) = Rewrite("select \"a:b\" from t where c = :c"); + statements[0].Should().Be("select \"a:b\" from t where c = $1"); + result.Names.Should().Equal("c"); + } + + [Fact] + public void InsideDollarQuote_Untouched() + { + var (statements, result) = Rewrite("do $$ begin perform :x; end $$;"); + statements[0].Should().Contain("perform :x;"); + result.HasNamed.Should().BeFalse(); + } + + [Fact] + public void InsideTaggedDollarQuote_Untouched() + { + var (statements, result) = Rewrite("select $tag$ :x $tag$, :y"); + statements[0].Should().Be("select $tag$ :x $tag$, $1"); + result.Names.Should().Equal("y"); + } + + [Fact] + public void PositionalParams_Detected_NotRewritten() + { + var (statements, result) = Rewrite("select * from t where a = $1 and b = $2"); + statements[0].Should().Be("select * from t where a = $1 and b = $2"); + result.HasPositional.Should().BeTrue(); + result.HasNamed.Should().BeFalse(); + } + + [Fact] + public void MixedStyles_BothFlagsSet() + { + var (_, result) = Rewrite("select $1, :id"); + result.HasPositional.Should().BeTrue(); + result.HasNamed.Should().BeTrue(); + } + + [Fact] + public void UnderscoreName_Recognized() + { + var (statements, result) = Rewrite("select :_user_id"); + statements[0].Should().Be("select $1"); + result.Names.Should().Equal("_user_id"); + } + + [Fact] + public void JsonbPathOperators_ColonInsideString_Untouched() + { + var (statements, result) = Rewrite("select data @? '$.a ? (@ > :x)' from t where id = :id"); + statements[0].Should().Be("select data @? '$.a ? (@ > :x)' from t where id = $1"); + result.Names.Should().Equal("id"); + } + + [Fact] + public void TypeHints_NamedColonForm_ResolvedByName() + { + var hints = SqlFileParser.ExtractParamTypeHints("param :user_id integer", ["email", "user_id"]); + hints.Should().NotBeNull(); + hints![1].Should().Be("integer"); + } + + [Fact] + public void TypeHints_TypeIsForm_ResolvedByName() + { + var hints = SqlFileParser.ExtractParamTypeHints("param user_id type is integer", ["user_id"]); + hints.Should().NotBeNull(); + hints![0].Should().Be("integer"); + } + + [Fact] + public void TypeHints_UnknownName_Ignored() + { + var hints = SqlFileParser.ExtractParamTypeHints("param :nope integer", ["user_id"]); + hints.Should().BeNull(); + } + + [Fact] + public void TypeHints_PositionalForm_StillWorks() + { + var hints = SqlFileParser.ExtractParamTypeHints("param $1 name integer"); + hints.Should().NotBeNull(); + hints![0].Should().Be("integer"); + } +} diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 2d703fc1..0e4a24d1 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -354,7 +354,37 @@ This is what makes the co-located test layout safe: without it, a test file's `/ > **Behavior change:** the default is `"*.test.sql"`, so files matching that suffix are **no longer exposed** as endpoints out of the box. If you previously relied on serving `*.test.sql` files, set `SkipPattern` to `""` to restore the old behavior. -## 3. Mute an individual logger with `"Off"` in `Log:MinimalLevels` +## 3. Named parameters in SQL files: `:name` + +SQL file endpoints can now use **named placeholders** instead of the positional `$1, $2, …`: + +```sql +/* +HTTP POST +@allow_anonymous +@single +*/ +select u.id, u.email, u.full_name as name, r.name as role +from users u +join roles r on r.id = u.role_id +where u.email = :email + and u.password_hash = crypt(:password, u.password_hash); +``` + +The placeholder **is** the parameter name: `:email` becomes the API parameter `email` (through the same `NameConverter` routine parameters use, so `:user_id` → `userId` with the default camelCase converter). The `@param $1 email text`-style annotations that existed only to *name* positional parameters are simply unnecessary — the file above needs none. Under the hood the SQL is rewritten to native `$N` before it is described and executed; PostgreSQL never sees the `:name` form, so type inference, `Describe`, and runtime behavior are identical to positional files. + +What you get: + +- **Repetition collapses**: the same name used multiple times — including **across statements** in a multi-command file — is **one** parameter (`where :user_id = author_id or :user_id = editor_id` takes a single `userId` value). +- **Claim mappings hook up by placeholder name**: `select :_user_id` under `@authorize` + `@user_parameters` binds the mapped claim with **zero** `@param` annotations. +- **Annotations match by name** where you still need them: `@param email default null` (defaults), `@param :email citext` (a Describe type hint), and the new **retype-without-rename** form `@param email type is citext` — renaming a parameter whose name came from its own placeholder would be nonsense, so `type is` changes only the type. All positional `@param $N …` forms keep working unchanged. +- **The tokenizer knows SQL**: strings (`'…'`, `"…"`, dollar-quoted bodies) and comments are untouched; `::int` casts, `:=` named-argument calls, and numeric slice bounds (`a[1:3]`) never match. A placeholder requires an identifier character immediately after the colon — the one caveat is an array slice with a *variable* bound, which must be written with a space (`a[1 : n]`). + +**One style per file**: mixing `$N` and `:name` in the same file makes the ordinal assignment ambiguous and is rejected (logged; the file is skipped under `ErrorMode: Skip`, or exits under `Exit`). + +**Why not `?` (JDBC style)?** Considered and rejected: `?`, `?|`, `?&`, and `@?` are PostgreSQL's own jsonb/geometric operators — `where data ? 'admin'` is legal, common SQL that no rewriter can reliably tell apart from a parameter. This is the same reason the PostgreSQL JDBC driver requires `??` escapes. Anonymous-positional already exists as `$N`. + +## 4. Mute an individual logger with `"Off"` in `Log:MinimalLevels` Each entry under **`Log:MinimalLevels`** now accepts **`"Off"`** (aliases **`"None"`** and **`"Silent"`**, case-insensitive) to **fully silence** that logger. Previously the only accepted values were the Serilog levels `Verbose…Fatal`, and there was no way to turn a logger off completely. @@ -382,4 +412,4 @@ Each named logger is controlled independently — e.g. mute the application logg ## Tests -Full test suite green (2364), including 63 unit tests for the test-file, HTTP-block, header-annotation, and include parsers plus the filter and tag matchers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown), watch mode (single-file rerun on a test change, full rerun on a fixture change, teardown on SIGINT/SIGTERM), path and tag filtering (including tags carried through a profile include), and the coverage report with a failing threshold gate. The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). +Full test suite green (2393), including 63 unit tests for the test-file, HTTP-block, header-annotation, and include parsers plus the filter and tag matchers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`), and 29 for named SQL-file parameters — 22 rewriter unit tests (casts, `:=`, slices, strings, dollar-quotes, jsonb-path strings, case-insensitive repetition, cross-statement sharing, mixing detection, named type hints) plus 7 end-to-end endpoint tests (auto-naming through the camelCase converter, a repeated placeholder bound from one value, required-parameter matching, name-matched defaults, `type is` retype, claim mapping by placeholder name, mixed-style rejection, and a multi-command file sharing `:id` across statements as one API parameter). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown), watch mode (single-file rerun on a test change, full rerun on a fixture change, teardown on SIGINT/SIGTERM), path and tag filtering (including tags carried through a profile include), and the coverage report with a failing threshold gate. The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). diff --git a/plugins/NpgsqlRest.SqlFileSource/README.md b/plugins/NpgsqlRest.SqlFileSource/README.md index 3777e2ea..2a1704b7 100644 --- a/plugins/NpgsqlRest.SqlFileSource/README.md +++ b/plugins/NpgsqlRest.SqlFileSource/README.md @@ -124,7 +124,17 @@ SELECT id, status FROM orders WHERE id = $1; ## Parameters -SQL files use PostgreSQL positional parameters (`$1`, `$2`, ...). Parameters are passed via query string (GET) or JSON body (POST/PUT/DELETE): +SQL files use PostgreSQL positional parameters (`$1`, `$2`, ...) or **named parameters** (`:name`) — one style per file (mixing is rejected). + +**Named parameters** carry their API name in the placeholder itself (converted with the configured `NameConverter`, camelCase by default), so no `@param` naming annotations are needed: + +```sql +SELECT * FROM users WHERE name = :user_name AND age > :age; +``` + +Gives: `GET /api/my-query?userName=hello&age=42`. The same name used repeatedly — including across statements in a multi-command file — is ONE parameter. The SQL is rewritten to native `$N` before describe/execution, so type inference and runtime behavior are identical to positional files. Strings, comments, and dollar-quoted bodies are untouched; `::` casts, `:=` calls, and numeric slice bounds (`a[1:3]`) never match (write a variable slice bound with a space: `a[1 : n]`). Annotations match named parameters by name: `@param user_name default null`, `@param :user_name citext` (type hint), or `@param user_name type is citext` (retype without rename). + +**Positional parameters** are passed via query string (GET) or JSON body (POST/PUT/DELETE): ``` GET /api/my-query?$1=hello&$2=42 diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs index 718658b5..02de1e58 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs @@ -1,3 +1,4 @@ +using System.Text; using NpgsqlRest.Common; namespace NpgsqlRest.SqlFileSource; @@ -84,6 +85,24 @@ public Method AutoHttpMethod } } +/// +/// Result of the named-parameter (:name) rewrite pass (). +/// +public sealed class NamedParamRewriteResult +{ + /// + /// Placeholder names in first-appearance order; index i corresponds to $(i+1) in the rewritten SQL. + /// The same name (case-insensitive) always maps to the same ordinal, file-wide. + /// + public List Names { get; } = []; + + /// The file uses :name placeholders. + public bool HasNamed => Names.Count > 0; + + /// A native positional ($N) placeholder was found outside strings/comments/dollar-quotes. + public bool HasPositional { get; set; } +} + /// /// Single-pass SQL file parser. Extracts comments and splits statements simultaneously. /// Handles: line comments (--), block comments (/* */), single-quoted strings (''), @@ -552,11 +571,12 @@ private static void ExtractVirtualParams(SqlFileParseResult result) /// /// Extract parameter type hints from @param annotations. - /// Looks for patterns like: @param $1 name type, @param $1 is name type - /// Only extracts when a positional $N and an explicit type are present. - /// Returns null if no type hints found. + /// Positional patterns: @param $1 name type, @param $1 is name type. + /// Named patterns (when is supplied — the file uses :name placeholders): + /// @param :name type, @param name type is type. + /// Only extracts when an explicit type is present. Returns null if no type hints found. /// - public static Dictionary? ExtractParamTypeHints(string? comment) + public static Dictionary? ExtractParamTypeHints(string? comment, IReadOnlyList? namedParams = null) { if (string.IsNullOrEmpty(comment)) return null; @@ -576,37 +596,68 @@ private static void ExtractVirtualParams(SqlFileParseResult result) var parts = s.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2 || parts[1][0] != '$') - { - continue; - } - - if (!int.TryParse(parts[1].AsSpan(1), out int paramIndex) || paramIndex < 1) + if (parts.Length < 2) { continue; } - // @param $1 name type ... → parts[3] is type candidate - // @param $1 is name type ... → parts[4] is type candidate + int paramIndex; string? typeName = null; - if (parts.Length >= 4 && parts[2].Equals("is", StringComparison.OrdinalIgnoreCase)) + + if (parts[1][0] == '$') { - if (parts.Length >= 5) + if (!int.TryParse(parts[1].AsSpan(1), out paramIndex) || paramIndex < 1) { - var candidate = parts[4].TrimEnd(';').ToLowerInvariant(); - if (candidate != "default" && candidate != "=") + continue; + } + + // @param $1 name type ... → parts[3] is type candidate + // @param $1 is name type ... → parts[4] is type candidate + if (parts.Length >= 4 && parts[2].Equals("is", StringComparison.OrdinalIgnoreCase)) + { + if (parts.Length >= 5) { - typeName = candidate; + typeName = TypeCandidate(parts[4]); } } + else if (parts.Length >= 4) + { + typeName = TypeCandidate(parts[3]); + } } - else if (parts.Length >= 4) + else if (namedParams is not null) { - var candidate = parts[3].TrimEnd(';').ToLowerInvariant(); - if (candidate != "default" && candidate != "=") + // @param :name type ... → parts[2] is type candidate + // @param name type is type ... → parts[4] is type candidate + string name; + int typeIdx; + if (parts[1][0] == ':' && parts[1].Length > 1) + { + name = parts[1][1..]; + typeIdx = 2; + } + else if (parts.Length >= 5 && + parts[2].Equals("type", StringComparison.OrdinalIgnoreCase) && + parts[3].Equals("is", StringComparison.OrdinalIgnoreCase)) { - typeName = candidate; + name = parts[1]; + typeIdx = 4; } + else + { + continue; + } + + paramIndex = IndexOfName(namedParams, name) + 1; + if (paramIndex < 1 || parts.Length <= typeIdx) + { + continue; + } + typeName = TypeCandidate(parts[typeIdx]); + } + else + { + continue; } if (typeName is not null) @@ -621,6 +672,181 @@ private static void ExtractVirtualParams(SqlFileParseResult result) } return hints; + + static string? TypeCandidate(string part) + { + var candidate = part.TrimEnd(';').ToLowerInvariant(); + return candidate is "default" or "=" ? null : candidate; + } + } + + /// 0-based index of a named parameter (case-insensitive), or -1. + public static int IndexOfName(IReadOnlyList namedParams, string name) + { + for (int i = 0; i < namedParams.Count; i++) + { + if (string.Equals(namedParams[i], name, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + return -1; + } + + /// + /// Rewrite named (:name) placeholders in the parsed statements to native positional ($N) placeholders. + /// Names are assigned ordinals in first-appearance order across the whole file (case-insensitive), so + /// the same name used repeatedly — including across statements — maps to the SAME parameter. The + /// rewritten SQL is what gets described and executed; PostgreSQL never sees the :name form. + /// + /// Token-aware: strings ('', ""), comments (-- and nested /* */), and dollar-quoted bodies are left + /// untouched. `::` casts, `:=` assignments, and numeric slice bounds (a[1:3]) never match because a + /// placeholder requires an identifier-start character immediately after the colon (a slice with a + /// variable bound, a[1:n], must be written with a space: a[1 : n]). + /// + /// Also detects native $N placeholders so the caller can reject files mixing both styles. + /// + public static NamedParamRewriteResult RewriteNamedParameters(List statements) + { + var result = new NamedParamRewriteResult(); + + for (int s = 0; s < statements.Count; s++) + { + var sql = statements[s]; + StringBuilder? sb = null; // created lazily on the first replacement + var state = State.Normal; + int blockDepth = 0; + string dollarTag = ""; + char quote = '\0'; + + for (int i = 0; i < sql.Length; i++) + { + char c = sql[i]; + char next = i + 1 < sql.Length ? sql[i + 1] : '\0'; + + switch (state) + { + case State.Normal: + if (c == '-' && next == '-') + { + state = State.LineComment; + } + else if (c == '/' && next == '*') + { + state = State.BlockComment; + blockDepth = 1; + sb?.Append(c); sb?.Append(next); i++; + continue; + } + else if (c == '\'' || c == '"') + { + state = State.SingleQuote; + quote = c; + } + else if (c == '$') + { + // Dollar-quote open ($$ / $tag$) vs positional param ($1). A dollar-quote tag + // must start with a letter/underscore, so $ followed by a digit is always $N. + if (next == '$' || char.IsAsciiLetter(next) || next == '_') + { + int t = i + 1; + while (t < sql.Length && (char.IsAsciiLetterOrDigit(sql[t]) || sql[t] == '_')) t++; + if (t < sql.Length && sql[t] == '$') + { + dollarTag = sql[i..(t + 1)]; + state = State.DollarQuote; + sb?.Append(dollarTag); + i = t; + continue; + } + } + else if (char.IsAsciiDigit(next)) + { + result.HasPositional = true; + } + } + else if (c == ':') + { + if (next == ':') + { + // `::` cast — copy both, never a placeholder + sb?.Append("::"); + i++; + continue; + } + if (char.IsAsciiLetter(next) || next == '_') + { + int e = i + 1; + while (e < sql.Length && (char.IsAsciiLetterOrDigit(sql[e]) || sql[e] == '_')) e++; + var name = sql[(i + 1)..e]; + int idx = IndexOfName(result.Names, name); + if (idx < 0) + { + result.Names.Add(name); + idx = result.Names.Count - 1; + } + sb ??= new StringBuilder(sql[..i], sql.Length); + sb.Append('$').Append(idx + 1); + i = e - 1; + continue; + } + } + break; + + case State.LineComment: + if (c == '\n') state = State.Normal; + break; + + case State.BlockComment: + if (c == '*' && next == '/') + { + if (--blockDepth == 0) state = State.Normal; + sb?.Append(c); sb?.Append(next); i++; + continue; + } + if (c == '/' && next == '*') + { + blockDepth++; + sb?.Append(c); sb?.Append(next); i++; + continue; + } + break; + + case State.SingleQuote: + if (c == quote) + { + if (next == quote) + { + // '' / "" escape — stay in the string + sb?.Append(c); sb?.Append(next); i++; + continue; + } + state = State.Normal; + } + break; + + case State.DollarQuote: + if (c == '$' && i + dollarTag.Length <= sql.Length + && sql.AsSpan(i, dollarTag.Length).SequenceEqual(dollarTag)) + { + sb?.Append(dollarTag); + i += dollarTag.Length - 1; + state = State.Normal; + continue; + } + break; + } + + sb?.Append(c); + } + + if (sb is not null) + { + statements[s] = sb.ToString(); + } + } + + return result; } private static void DetectMutation(ReadOnlySpan word, SqlFileParseResult result) diff --git a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs index 7edf6c19..8c869bed 100644 --- a/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs +++ b/plugins/NpgsqlRest.SqlFileSource/SqlFileSource.cs @@ -127,23 +127,42 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( return null; } + // Named-parameter (:name) rewrite: placeholders become native $N before Describe/execution + // (PostgreSQL never sees the :name form); the names flow into parameter ActualName, so the API + // name — and claim mappings — come from the placeholder itself. Same name = same parameter, + // file-wide. Mixing $N and :name in one file is ambiguous and rejected. + var namedParams = SqlFileParser.RewriteNamedParameters(parseResult.Statements); + if (namedParams.HasNamed && namedParams.HasPositional) + { + NpgsqlRestOptions.Logger?.LogError( + "SqlFileSource: {FilePath}: mixing positional ($N) and named (:name) parameters in the same file is not supported — use one style.", + filePath); + if (options.ErrorMode == ParseErrorMode.Exit) + { + NpgsqlRestOptions.Logger?.LogCritical("SqlFileSource: Exiting due to SQL file error. Set ErrorMode to Skip to continue past errors."); + Environment.Exit(1); + } + return null; + } + IReadOnlyList? namedList = namedParams.HasNamed ? namedParams.Names : null; + bool isMultiCommand = parseResult.Statements.Count > 1; // Check @param annotations for HTTP client type parameters (single composite, no SQL rewriting) Dictionary? httpTypeParams = null; if (NpgsqlRestOptions.Options.HttpClientOptions.Enabled && HttpClientTypes.Definitions.Count > 0) { - httpTypeParams = DetectHttpTypeParams(parseResult.Comment, connection); + httpTypeParams = DetectHttpTypeParams(parseResult.Comment, connection, namedList); } // Check @param annotations for non-HTTP composite type parameters (single composite, no SQL rewriting) - var compositeTypeParams = DetectCompositeTypeParams(parseResult.Comment, connection, httpTypeParams); + var compositeTypeParams = DetectCompositeTypeParams(parseResult.Comment, connection, httpTypeParams, namedList); // Describe each statement individually and merge parameters int mergedMaxParam = 0; var paramTypesByIndex = new Dictionary(); // $N index → type name var commandDescribes = new List(); - var paramTypeHints = SqlFileParser.ExtractParamTypeHints(parseResult.Comment); + var paramTypeHints = SqlFileParser.ExtractParamTypeHints(parseResult.Comment, namedList); for (int stmtIndex = 0; stmtIndex < parseResult.Statements.Count; stmtIndex++) { @@ -249,6 +268,8 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( for (int i = 0; i < mergedMaxParam; i++) { var typeName = paramTypesByIndex.GetValueOrDefault(i, "unknown"); + // Named (:name) placeholder for this ordinal, when the file uses them. + string? placeholderName = namedList is not null && i < namedList.Count ? namedList[i] : null; string? customType = null; string? customTypeName = null; @@ -273,7 +294,11 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( } else { - convertedName = $"${i + 1}"; + // Named placeholders get their API name from the placeholder itself, through the same + // NameConverter routine parameters use (:user_id → userId); positional stay "$N". + convertedName = placeholderName is not null + ? nameConverter(placeholderName) ?? placeholderName + : $"${i + 1}"; } var typeDescriptor = new TypeDescriptor( @@ -283,7 +308,7 @@ private static (Routine, IRoutineSourceParameterFormatter)? ProcessFile( customTypePosition: customTypePosition, originalParameterName: originalParameterName, customTypeName: customTypeName); - var positionalName = $"${i + 1}"; + var positionalName = placeholderName ?? $"${i + 1}"; var param = new NpgsqlRestParameter( ordinal: i, @@ -488,22 +513,47 @@ private static void ResolveCompositeType(TypeDescriptor descriptor) CompositeTypeCache.ResolveTypeDescriptor(descriptor); } + /// + /// Collect typed @param annotations as (0-based index, API param name, type name) tuples. + /// Positional form: @param $N name type. Named form (only for files using :name placeholders): + /// @param :name type — the API name comes from the placeholder itself, through the NameConverter. + /// + private static List<(int ParamIndex, string ParamName, string TypeName)> CollectTypedParamAnnotations( + string comment, IReadOnlyList? namedParams) + { + List<(int, string, string)> annotations = []; + foreach (Match match in Regex.Matches(comment, @"@param\s+\$(\d+)\s+(\w+)\s+(\S+)", RegexOptions.IgnoreCase)) + { + annotations.Add((int.Parse(match.Groups[1].Value) - 1, match.Groups[2].Value, match.Groups[3].Value)); + } + if (namedParams is not null) + { + foreach (Match match in Regex.Matches(comment, @"@param\s+:(\w+)\s+(\S+)", RegexOptions.IgnoreCase)) + { + var name = match.Groups[1].Value; + var paramIndex = SqlFileParser.IndexOfName(namedParams, name); + if (paramIndex < 0) + { + continue; + } + var paramName = NpgsqlRestOptions.Options.NameConverter(name) ?? name; + annotations.Add((paramIndex, paramName, match.Groups[2].Value)); + } + } + return annotations; + } + /// /// Scan @param annotations in the comment for HTTP client type parameters. /// Returns null if no HTTP types found, otherwise a dictionary keyed by 0-based param index. /// private static Dictionary? - DetectHttpTypeParams(string comment, NpgsqlConnection connection) + DetectHttpTypeParams(string comment, NpgsqlConnection connection, IReadOnlyList? namedParams) { Dictionary? result = null; - // Match @param $N name type patterns - foreach (Match match in Regex.Matches(comment, @"@param\s+\$(\d+)\s+(\w+)\s+(\S+)", RegexOptions.IgnoreCase)) + foreach (var (paramIndex, paramName, typeName) in CollectTypedParamAnnotations(comment, namedParams)) { - var paramIndex = int.Parse(match.Groups[1].Value) - 1; // 0-based - var paramName = match.Groups[2].Value; - var typeName = match.Groups[3].Value; - // Check if this type is an HTTP client type (try with and without public. prefix) string? resolvedTypeName = null; if (HttpClientTypes.Definitions.ContainsKey(typeName)) @@ -579,16 +629,13 @@ and a.attnum > 0 and not a.attisdropped DetectCompositeTypeParams( string comment, NpgsqlConnection connection, - Dictionary? httpTypeParams) + Dictionary? httpTypeParams, + IReadOnlyList? namedParams) { Dictionary? result = null; - foreach (Match match in Regex.Matches(comment, @"@param\s+\$(\d+)\s+(\w+)\s+(\S+)", RegexOptions.IgnoreCase)) + foreach (var (paramIndex, paramName, typeName) in CollectTypedParamAnnotations(comment, namedParams)) { - var paramIndex = int.Parse(match.Groups[1].Value) - 1; // 0-based - var paramName = match.Groups[2].Value; - var typeName = match.Groups[3].Value; - // Skip if already detected as HTTP type if (httpTypeParams is not null && httpTypeParams.ContainsKey(paramIndex)) { From f859a4f051b6d3a2645d1eb44d2519c948eab62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 18:30:34 +0200 Subject: [PATCH 09/14] =?UTF-8?q?feat(watch):=20server=20watch=20mode=20?= =?UTF-8?q?=E2=80=94=20--watch=20restarts=20the=20server=20on=20SQL/config?= =?UTF-8?q?=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --watch is now a two-mode flag: with --test it is the test runner's in-process watch (unchanged); WITHOUT --test it runs the SERVER under a watch supervisor. Supervisor/child design (same model as dotnet watch): the process spawns itself as a child server (marked via NPGSQLREST_WATCH_CHILD; value = the supervisor PID) and watches the SqlFileSource tree plus the configuration files. On a debounced change it stops the child gracefully and starts a fresh one — the child runs the completely normal server pipeline (discovery, describe, code generation, Kestrel), so dev is production behavior. The one relaxation: SqlFileSource ErrorMode is forced Exit->Skip in the watch child so a broken SQL file logs its error and drops only its own endpoint. Details: - Edits to files matching SqlFileSource.SkipPattern (test files) do NOT restart the server. - Child crash: the supervisor prints "server exited (code N) — waiting for file changes" and revives on the next relevant save (no crash-looping). - Graceful stop: SIGTERM via libc DllImport on Unix (Process.Kill is SIGKILL); hard process-tree kill on Windows (a dev server has no teardown needs). - Parent watchdog in the child: if the supervisor dies without stopping it (SIGKILL, wrapper runners like bun run), the child exits by itself — no orphan holding the port. - Spawn works from the AOT binary (Environment.ProcessPath) and under `dotnet NpgsqlRestClient.dll` (dotnet host re-invoked with the dll). - DOTNET_USE_POLLING_FILE_WATCHER=1 switches to a 1s polling scan for environments where file events don't cross the filesystem boundary (Docker Desktop bind mounts). - --watch without --test and without an enabled SqlFileSource exits with a clear error (nothing to watch). - WatchUtils extracts the shared watch primitives (base dir, debounce, pattern match); the test runner delegates to it, behavior identical. Plan B (in-process endpoint hot-swap via a dynamic EndpointDataSource, target 3.20) is designed in scrap/WATCH_HOT_SWAP_PLAN.md. Verified live across 12 scenarios: initial serve, add/break/fix endpoint file (broken file 404s while the rest keeps serving), test-file edit ignored, config-edit restart, child crash recovery, supervisor SIGKILL -> child self-exit (no orphans), SIGTERM graceful stop with port freed, polling mode, nothing-to-watch error, and test mode unaffected. Full suite green (2393). Changelog: watch documented as a single two-mode feature. --- NpgsqlRestClient/App.cs | 2 +- NpgsqlRestClient/Program.cs | 19 +- NpgsqlRestClient/Testing/TestRunner.cs | 51 +---- NpgsqlRestClient/WatchSupervisor.cs | 300 +++++++++++++++++++++++++ NpgsqlRestClient/WatchUtils.cs | 141 ++++++++++++ changelog/v3.19.0.md | 164 +++++++++----- 6 files changed, 573 insertions(+), 104 deletions(-) create mode 100644 NpgsqlRestClient/WatchSupervisor.cs create mode 100644 NpgsqlRestClient/WatchUtils.cs diff --git a/NpgsqlRestClient/App.cs b/NpgsqlRestClient/App.cs index 8978f83b..fe1fcd90 100644 --- a/NpgsqlRestClient/App.cs +++ b/NpgsqlRestClient/App.cs @@ -690,7 +690,7 @@ public List CreateEndpointSources(bool relaxSqlFileErrors = fal // session (Environment.Exit) on a broken SQL file — Skip drops the file instead, and // the runner reports the endpoint delta after each rebuild. opts.ErrorMode = ParseErrorMode.Skip; - _builder.TestLogger?.LogDebug("watch mode: SqlFileSource ErrorMode forced from Exit to Skip"); + (_builder.TestLogger ?? _builder.ClientLogger)?.LogDebug("watch mode: SqlFileSource ErrorMode forced from Exit to Skip"); } var resultPrefix = _config.GetConfigStr("ResultPrefix", sqlFileSourceCfg); if (resultPrefix is not null) diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index ea0f29c1..641524ab 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -40,6 +40,7 @@ ("npgsqlrest [files...] --endpoints", "Connect to database, discover endpoints, output as JSON, then exit. Syntax highlighted in terminal, plain JSON when piped."), ("npgsqlrest [files...] --test", "Run SQL test files (TestRunner config), then exit. Exit codes: 0 pass, 1 failures, 2 errors, 3 config error, 4 no tests."), ("npgsqlrest [files...] --test --watch", "Watch mode: run the tests, then re-run on file changes until Ctrl+C (interactive/dev-only; teardown runs on exit)."), + ("npgsqlrest [files...] --watch", "Server watch mode (dev): run the server and restart it on SQL file source or configuration file changes until Ctrl+C. Requires an enabled SqlFileSource."), (" ", " "), ("npgsqlrest --hash [value]", "Hash value with default hasher and print to console."), ("npgsqlrest --basic_auth [username] [password]", "Print out basic basic auth header value in format 'Authorization: Basic base64(username:password)'."), @@ -276,6 +277,20 @@ bool watchMode = testMode && (args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) || config.GetConfigBool("Watch", config.Cfg.GetSection("TestRunner"))); +// Server watch mode (--watch WITHOUT --test): this process becomes a supervisor that spawns itself as +// a child server and restarts it on SQL/config file changes; the child (marked by an env variable) +// falls through and runs the normal server pipeline. See WatchSupervisor. +if (testMode is false && args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase))) +{ + if (WatchSupervisor.IsChild is false) + { + Environment.ExitCode = await WatchSupervisor.RunAsync(config, args); + return; + } + // The child exits by itself if the supervisor dies without stopping it (no orphan on the port). + WatchSupervisor.StartParentWatchdog(); +} + builder.BuildInstance(); builder.Instance.Services.AddRouting(); if (!endpointsMode) @@ -478,7 +493,9 @@ SseResponseHeaders = builder.GetSseResponseHeaders(), WarnUnboundSseNotices = config.GetConfigBool("WarnUnboundServerSentEventsNotices", config.NpgsqlRestCfg, true), - EndpointSources = appInstance.CreateEndpointSources(relaxSqlFileErrors: watchMode), + // Both watch flavors must survive a broken SQL file: the test runner's in-process watch and the + // server watch child (supervisor restarts it on the next save either way). + EndpointSources = appInstance.CreateEndpointSources(relaxSqlFileErrors: watchMode || WatchSupervisor.IsChild), UploadOptions = appInstance.CreateUploadOptions(), CacheOptions = builder.BuildCacheOptions(app, cacheType), diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 0c56c075..23c1c237 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -531,13 +531,9 @@ FileSystemWatcher NewWatcher(string dir) } } - // A cwd-relative path against a cwd-relative glob (leading "./" tolerated on the pattern). + // A cwd-relative path against a cwd-relative glob (shared with the server watch supervisor). private static bool MatchesCwdRelativePattern(string relativePath, string pattern) - { - pattern = pattern.Replace('\\', '/'); - if (pattern.StartsWith("./")) pattern = pattern[2..]; - return Parser.IsPatternMatch(relativePath, pattern); - } + => WatchUtils.MatchesCwdRelativePattern(relativePath, pattern); // Rebuild delta: how many endpoints now, plus every added/removed "METHOD path" — a removed endpoint // usually means its SQL file failed to parse/describe and was skipped. @@ -564,46 +560,13 @@ static HashSet Keys(RoutineEndpoint[] eps) => after.Length, before.Length, added.Count, removed.Count); } - // First change (blocking), then a quiet period so editor write bursts coalesce into one rerun. - private static async Task?> NextChangeBatchAsync(System.Threading.Channels.ChannelReader reader, CancellationToken ct) - { - string first; - try - { - first = await reader.ReadAsync(ct); - } - catch (OperationCanceledException) - { - return null; - } - var batch = new List { first }; - while (true) - { - try { await Task.Delay(300, ct); } catch (OperationCanceledException) { return null; } - bool any = false; - while (reader.TryRead(out var more)) - { - batch.Add(more); - any = true; - } - if (!any) return batch; - } - } + // First change (blocking) + quiet-period coalescing (shared with the server watch supervisor). + private static Task?> NextChangeBatchAsync(System.Threading.Channels.ChannelReader reader, CancellationToken ct) + => WatchUtils.NextChangeBatchAsync(reader, ct); - // The deepest fixed (wildcard-free) directory of the FilePattern — same logic DiscoverFiles uses. + // The deepest fixed (wildcard-free) directory of the FilePattern (shared with the supervisor). private static string? GetWatchBaseDir(string pattern) - { - if (string.IsNullOrWhiteSpace(pattern)) return null; - int firstWildcard = pattern.IndexOfAny(['*', '?']); - if (firstWildcard < 0) - { - var dir = Path.GetDirectoryName(pattern); - return string.IsNullOrEmpty(dir) ? "." : dir; - } - int lastSlash = pattern.LastIndexOf('/', firstWildcard); - var baseDir = lastSlash >= 0 ? pattern[..lastSlash] : "."; - return Directory.Exists(baseDir) ? baseDir : null; - } + => WatchUtils.GetWatchBaseDir(pattern); private async Task RunFileAsync(string file, IReadOnlyDictionary lookup, CancellationToken runCt) { diff --git a/NpgsqlRestClient/WatchSupervisor.cs b/NpgsqlRestClient/WatchSupervisor.cs new file mode 100644 index 00000000..829caa9b --- /dev/null +++ b/NpgsqlRestClient/WatchSupervisor.cs @@ -0,0 +1,300 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading.Channels; + +namespace NpgsqlRestClient; + +/// +/// Server watch mode (`--watch` without `--test`): this process becomes a tiny SUPERVISOR that never +/// builds the server itself — it spawns this same executable as a CHILD (marked via an environment +/// variable), watches the SqlFileSource tree and the configuration files, and restarts the child on +/// every debounced change. The child runs the completely normal server pipeline (production parity), +/// with one relaxation: SqlFileSource ErrorMode is forced from Exit to Skip so a broken SQL file logs +/// its error and drops that endpoint instead of killing the server (see App.CreateEndpointSources). +/// +/// Design notes: +/// - A child process (vs. in-process rebuild) guarantees clean state: Kestrel's port binding and +/// ASP.NET's route table cannot be torn down in-process (routes can be added, never removed), and +/// static caches would accumulate. This is the same conclusion `dotnet watch` and nodemon reached. +/// The in-process hot-swap upgrade is designed in scrap/WATCH_HOT_SWAP_PLAN.md (target 3.20). +/// - Graceful stop: SIGTERM via libc on Unix (Process.Kill would be SIGKILL), hard kill fallback after +/// a timeout; on Windows there is no SIGTERM for console children — hard process-tree kill, which is +/// acceptable here because a dev server holds nothing that needs teardown. +/// - The child runs a parent WATCHDOG: if the supervisor dies without stopping it (SIGKILL, a wrapper +/// runner like `bun run` murdering the process group), the child exits by itself — no orphan holding +/// the port. +/// +public static class WatchSupervisor +{ + /// Set on the child process; the value is the supervisor's PID (for the parent watchdog). + public const string ChildEnv = "NPGSQLREST_WATCH_CHILD"; + + public static bool IsChild { get; } = string.IsNullOrEmpty(Environment.GetEnvironmentVariable(ChildEnv)) is false; + + // Blittable signature — plain DllImport is AOT-safe here and avoids AllowUnsafeBlocks. + [DllImport("libc", SetLastError = true, EntryPoint = "kill")] + private static extern int kill(int pid, int sig); + private const int SIGTERM = 15; + + /// + /// Called in the CHILD: exit when the supervisor process disappears without stopping us first, + /// so a SIGKILLed supervisor never leaves an orphaned server holding the port. + /// + public static void StartParentWatchdog() + { + if (int.TryParse(Environment.GetEnvironmentVariable(ChildEnv), out var parentPid) is false) + { + return; + } + var thread = new Thread(() => + { + try + { + Process.GetProcessById(parentPid).WaitForExit(); + } + catch + { + // already gone + } + Environment.Exit(143); + }) + { + IsBackground = true, + Name = "watch-parent-watchdog", + }; + thread.Start(); + } + + /// Supervisor main loop. Returns the process exit code. + public static async Task RunAsync(Config config, string[] args) + { + var output = new Out(); + + // Something to watch? Server watch requires an enabled SqlFileSource (database routines have no + // files to watch — a change in the database needs a manual restart either way). + var sqlFileCfg = config.NpgsqlRestCfg.GetSection("SqlFileSource"); + string? filePattern = null; + if (sqlFileCfg.Exists() && config.GetConfigBool("Enabled", sqlFileCfg, true)) + { + filePattern = config.GetConfigStr("FilePattern", sqlFileCfg); + } + if (string.IsNullOrWhiteSpace(filePattern)) + { + output.LineAnsi( + "watch: nothing to watch — --watch without --test requires an enabled SqlFileSource with a FilePattern. For test watch mode use --test --watch.", + Testing.TestRunner.AnsiFail); + return 1; + } + var baseDir = WatchUtils.GetWatchBaseDir(filePattern); + if (baseDir is null) + { + output.LineAnsi( + $"watch: cannot watch — SqlFileSource FilePattern \"{filePattern}\" has no existing base directory.", + Testing.TestRunner.AnsiFail); + return 1; + } + var baseDirFull = Path.GetFullPath(baseDir); + var skipPattern = (config.GetConfigStr("SkipPattern", sqlFileCfg) ?? "*.test.sql").Replace('\\', '/'); + + // Config files restart the server too: the .json/.jsonc files passed on the command line, plus + // the implicit default when present. + var configFiles = args + .Where(a => a.StartsWith('-') is false + && (a.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || a.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)) + && File.Exists(a)) + .Select(Path.GetFullPath) + .ToList(); + if (File.Exists("appsettings.json")) + { + configFiles.Add(Path.GetFullPath("appsettings.json")); + } + configFiles = [.. configFiles.Distinct(StringComparer.Ordinal)]; + var configFileSet = new HashSet(configFiles, StringComparer.Ordinal); + + using var stop = new CancellationTokenSource(); + bool stopping = false; + void OnSignal(PosixSignalContext ctx) + { + // The child receives Ctrl+C from the terminal group by itself; for SIGTERM (docker stop, + // where only PID 1 is signalled) the shutdown path below forwards the stop to the child. + ctx.Cancel = true; + stopping = true; + stop.Cancel(); + } + using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal); + using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal); + + // Watchers (or the polling fallback for bind-mount environments) feed one debounced channel. + var changes = Channel.CreateUnbounded(); + var watchers = new List(); + bool polling = WatchUtils.UsePollingWatcher; + if (polling) + { + WatchUtils.StartPollingWatcher(baseDirFull, configFiles, changes.Writer, stop.Token); + } + else + { + watchers.Add(WatchUtils.NewSqlWatcher(baseDirFull, changes.Writer)); + foreach (var dir in configFiles.Select(Path.GetDirectoryName).Where(d => d is not null).Distinct(StringComparer.Ordinal)) + { + var w = new FileSystemWatcher(dir!) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + void OnFsEvent(object s, FileSystemEventArgs e) + { + if (configFileSet.Contains(e.FullPath)) + { + changes.Writer.TryWrite(e.FullPath); + } + } + w.Changed += OnFsEvent; + w.Created += OnFsEvent; + w.Renamed += (s, e) => { if (configFileSet.Contains(e.FullPath)) changes.Writer.TryWrite(e.FullPath); }; + w.EnableRaisingEvents = true; + watchers.Add(w); + } + } + + var watchingWhat = $"{Path.GetRelativePath(Environment.CurrentDirectory, baseDirFull)} (*.sql)" + + (configFiles.Count > 0 ? " + config files" : "") + + (polling ? " [polling]" : ""); + output.Line($"watch mode: supervising the server process, watching {watchingWhat} — Ctrl+C to stop", ConsoleColor.Cyan); + + Process child = Spawn(args); + Task exitTask = child.WaitForExitAsync(CancellationToken.None); + + try + { + while (stopping is false) + { + var batchTask = WatchUtils.NextChangeBatchAsync(changes.Reader, stop.Token); + var completed = await Task.WhenAny(exitTask, batchTask); + + if (stopping) + { + break; + } + + if (completed == exitTask && batchTask.IsCompleted is false) + { + // The child died on its own (crash, config error, port conflict). Don't respawn in a + // loop — hold until the next file change, then try again. + output.Line($"\nserver exited (code {child.ExitCode}) — waiting for file changes", ConsoleColor.Yellow); + var wakeBatch = await batchTask; + if (wakeBatch is null || stopping) + { + break; + } + if (Relevant(wakeBatch, baseDirFull, skipPattern, configFileSet) is not { } wakeTrigger) + { + // Irrelevant change (e.g. a *.test.sql edit) — keep holding. + continue; + } + output.Line($"— {DateTime.Now:HH:mm:ss} change detected ({wakeTrigger}) — restarting —", ConsoleColor.Cyan); + child = Spawn(args); + exitTask = child.WaitForExitAsync(CancellationToken.None); + continue; + } + + var batch = await batchTask; + if (batch is null) + { + break; + } + if (Relevant(batch, baseDirFull, skipPattern, configFileSet) is not { } trigger) + { + continue; + } + output.Line($"\n— {DateTime.Now:HH:mm:ss} change detected ({trigger}) — restarting —", ConsoleColor.Cyan); + StopChild(child); + child = Spawn(args); + exitTask = child.WaitForExitAsync(CancellationToken.None); + } + return 0; + } + finally + { + foreach (var w in watchers) + { + w.Dispose(); + } + StopChild(child); + } + } + + /// + /// First relevant path in the batch (cwd-relative, for display), or null when the whole batch is + /// noise. Relevant: a config file, or a *.sql file under the watched tree that matches the + /// SqlFileSource FilePattern and does NOT match SkipPattern (a test-file edit must not bounce the + /// server). + /// + private static string? Relevant(List batch, string baseDirFull, string skipPattern, HashSet configFileSet) + { + foreach (var path in batch.Distinct(StringComparer.Ordinal)) + { + var full = Path.GetFullPath(path); + var rel = Path.GetRelativePath(Environment.CurrentDirectory, full).Replace('\\', '/'); + if (configFileSet.Contains(full)) + { + return rel; + } + if (full.StartsWith(baseDirFull, StringComparison.Ordinal) + && rel.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) + && WatchUtils.MatchesCwdRelativePattern(rel, skipPattern) is false) + { + return rel; + } + } + return null; + } + + private static Process Spawn(string[] args) + { + var exe = Environment.ProcessPath ?? "dotnet"; + var psi = new ProcessStartInfo(exe) + { + UseShellExecute = false, // stdout/stderr inherited — the child's logs are the console output + }; + // Under `dotnet NpgsqlRestClient.dll ...` ProcessPath is the dotnet host; argv[0] is the dll path. + if (string.Equals(Path.GetFileNameWithoutExtension(exe), "dotnet", StringComparison.OrdinalIgnoreCase)) + { + psi.ArgumentList.Add(Environment.GetCommandLineArgs()[0]); + } + foreach (var a in args) + { + psi.ArgumentList.Add(a); + } + psi.Environment[ChildEnv] = Environment.ProcessId.ToString(); + return Process.Start(psi)!; + } + + private static void StopChild(Process child) + { + try + { + if (child.HasExited) + { + return; + } + if (OperatingSystem.IsWindows()) + { + // No SIGTERM for console children on Windows; a dev server has nothing needing teardown. + child.Kill(entireProcessTree: true); + } + else + { + kill(child.Id, SIGTERM); // graceful: Kestrel drains and stops + if (child.WaitForExit(5000) is false) + { + child.Kill(entireProcessTree: true); + } + } + child.WaitForExit(); + } + catch + { + // already gone + } + } +} diff --git a/NpgsqlRestClient/WatchUtils.cs b/NpgsqlRestClient/WatchUtils.cs new file mode 100644 index 00000000..43548d71 --- /dev/null +++ b/NpgsqlRestClient/WatchUtils.cs @@ -0,0 +1,141 @@ +using System.Threading.Channels; +using NpgsqlRest; + +namespace NpgsqlRestClient; + +/// +/// Shared file-watching primitives used by both watch modes: the test runner's in-process watch +/// () and the server watch supervisor (). +/// +public static class WatchUtils +{ + /// A cwd-relative path against a cwd-relative glob (leading "./" tolerated on the pattern). + public static bool MatchesCwdRelativePattern(string relativePath, string pattern) + { + pattern = pattern.Replace('\\', '/'); + if (pattern.StartsWith("./")) + { + pattern = pattern[2..]; + } + return Parser.IsPatternMatch(relativePath, pattern); + } + + /// + /// The deepest fixed (wildcard-free) directory of a glob pattern — same logic file discovery uses. + /// Null when the directory does not exist. + /// + public static string? GetWatchBaseDir(string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return null; + } + int firstWildcard = pattern.IndexOfAny(['*', '?']); + if (firstWildcard < 0) + { + var dir = Path.GetDirectoryName(pattern); + return string.IsNullOrEmpty(dir) ? "." : dir; + } + int lastSlash = pattern.LastIndexOf('/', firstWildcard); + var baseDir = lastSlash >= 0 ? pattern[..lastSlash] : "."; + return Directory.Exists(baseDir) ? baseDir : null; + } + + /// + /// First change (blocking), then a quiet period so editor write bursts coalesce into one batch. + /// Returns null when cancelled. + /// + public static async Task?> NextChangeBatchAsync(ChannelReader reader, CancellationToken ct) + { + string first; + try + { + first = await reader.ReadAsync(ct); + } + catch (OperationCanceledException) + { + return null; + } + var batch = new List { first }; + while (true) + { + try { await Task.Delay(300, ct); } catch (OperationCanceledException) { return null; } + bool any = false; + while (reader.TryRead(out var more)) + { + batch.Add(more); + any = true; + } + if (!any) return batch; + } + } + + /// + /// A recursive *.sql FileSystemWatcher over a directory, feeding full paths into the channel. + /// + public static FileSystemWatcher NewSqlWatcher(string fullDir, ChannelWriter writer) + { + var w = new FileSystemWatcher(fullDir, "*.sql") + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + }; + void OnFsEvent(object s, FileSystemEventArgs e) => writer.TryWrite(e.FullPath); + w.Changed += OnFsEvent; + w.Created += OnFsEvent; + w.Renamed += (s, e) => writer.TryWrite(e.FullPath); + w.EnableRaisingEvents = true; + return w; + } + + /// + /// True when the ecosystem-standard DOTNET_USE_POLLING_FILE_WATCHER is set — the escape hatch for + /// environments where inotify events don't cross the filesystem boundary (Docker Desktop bind mounts, + /// some network shares). + /// + public static bool UsePollingWatcher => + Environment.GetEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER") is { } v + && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + + /// + /// Polling fallback: scans *.sql under plus the extra files every second + /// and writes changed/new paths into the channel. Runs until the token is cancelled. + /// + public static void StartPollingWatcher(string fullDir, IReadOnlyList extraFiles, ChannelWriter writer, CancellationToken ct) + { + _ = Task.Run(async () => + { + var known = new Dictionary(StringComparer.Ordinal); + void Scan(bool emit) + { + IEnumerable files = Directory.Exists(fullDir) + ? Directory.EnumerateFiles(fullDir, "*.sql", SearchOption.AllDirectories) + : []; + foreach (var f in files.Concat(extraFiles)) + { + DateTime mtime; + try { mtime = File.GetLastWriteTimeUtc(f); } catch { continue; } + if (known.TryGetValue(f, out var prev)) + { + if (prev != mtime) + { + known[f] = mtime; + if (emit) writer.TryWrite(f); + } + } + else + { + known[f] = mtime; + if (emit) writer.TryWrite(f); + } + } + } + Scan(emit: false); + while (ct.IsCancellationRequested is false) + { + try { await Task.Delay(1000, ct); } catch (OperationCanceledException) { break; } + Scan(emit: true); + } + }, CancellationToken.None); + } +} diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 0e4a24d1..93cf8561 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -1,10 +1,10 @@ # Changelog v3.19.0 -## Version [3.19.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.19.0) (2026-07-02) +## Version [3.19.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.19.0) (2026-07-03) [Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.18.2...3.19.0) -This release introduces the **SQL test runner** (`npgsqlrest --test`) — write tests for your endpoints as plain `.sql` files, invoke endpoints in-process from inside a test, and assert on both the HTTP response and the database state, all within the test's own transaction. Also included: a **skip glob for the SQL file source** and the ability to **mute an individual logger**. +This release introduces the **SQL test runner** (`npgsqlrest --test`) — write tests for your endpoints as plain `.sql` files, invoke endpoints in-process from inside a test, and assert on both the HTTP response and the database state, all within the test's own transaction. Also included: **watch mode** (`--watch` — re-run tests or restart the server on SQL and configuration changes), **named parameters in SQL files** (`:name`), a **skip glob for the SQL file source**, and the ability to **mute an individual logger**. --- @@ -163,13 +163,17 @@ Run-once steps around the whole test session. **`Setup` runs before endpoint dis Steps can be defined once in the **`Steps` registry** (name → step, like `ConnectionStrings` or `CacheOptions.Profiles`) and referenced by name; `Setup`/`Teardown` arrays accept names and inline objects mixed. Referencing an unknown name is a configuration error (exit 3). ```jsonc -"Steps": { - "CreateDatabase": { "Sql": "create database app_test_{rnd5}", "ConnectionName": "Admin" }, - "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, - "DropDatabase": { "Sql": "drop database if exists app_test_{rnd5} with (force)", "ConnectionName": "Admin" } -}, -"Setup": ["CreateDatabase", "ApplyMigrations"], -"Teardown": ["DropDatabase"] +{ + "TestRunner": { + "Steps": { + "CreateDatabase": { "Sql": "create database app_test_{rnd5}", "ConnectionName": "Admin" }, + "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, + "DropDatabase": { "Sql": "drop database if exists app_test_{rnd5} with (force)", "ConnectionName": "Admin" } + }, + "Setup": ["CreateDatabase", "ApplyMigrations"], + "Teardown": ["DropDatabase"] + } +} ``` ### Per-file setup, teardown, and connection (header annotations) @@ -200,21 +204,23 @@ Together these give **per-test database isolation**: a file's setup clones a mig `TestRunner.ConnectionName` points the whole test session — endpoint type-checking (SQL-file Describe), endpoint execution, and the tests — at a named `ConnectionStrings` entry instead of the app's main connection. That database **need not exist at startup**: it is never opened before Setup, so the first Setup step can create it. ```jsonc -"ConnectionStrings": { - "Default": "Host={PGHOST};Database=appdb;...", // the real app DB — untouched by tests - "Admin": "Host={PGHOST};Database=postgres;...", // maintenance (needs CREATEDB) - "Test": "Host={PGHOST};Database=app_test_{rnd6};..." // the throwaway test DB -}, -"TestRunner": { - "ConnectionName": "Test", - "FilePattern": "./tests/**/*.test.sql", - "Steps": { - "CreateDatabase": { "Sql": "create database app_test_{rnd6}", "ConnectionName": "Admin" }, - "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, // runs on "Test" - "DropDatabase": { "Sql": "drop database if exists app_test_{rnd6} with (force)", "ConnectionName": "Admin" } +{ + "ConnectionStrings": { + "Default": "Host={PGHOST};Database=appdb;...", // the real app DB — untouched by tests + "Admin": "Host={PGHOST};Database=postgres;...", // maintenance (needs CREATEDB) + "Test": "Host={PGHOST};Database=app_test_{rnd6};..." // the throwaway test DB }, - "Setup": ["CreateDatabase", "ApplyMigrations"], - "Teardown": ["DropDatabase"] + "TestRunner": { + "ConnectionName": "Test", + "FilePattern": "./tests/**/*.test.sql", + "Steps": { + "CreateDatabase": { "Sql": "create database app_test_{rnd6}", "ConnectionName": "Admin" }, + "ApplyMigrations": { "SqlFile": "./migrations/schema.sql" }, // runs on "Test" + "DropDatabase": { "Sql": "drop database if exists app_test_{rnd6} with (force)", "ConnectionName": "Admin" } + }, + "Setup": ["CreateDatabase", "ApplyMigrations"], + "Teardown": ["DropDatabase"] + } } ``` @@ -253,40 +259,44 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v - `raise notice`/`warning` from the database — logged **by their severity**, tagged with the test file that emitted them. ```jsonc -"Log": { "MinimalLevels": { "NpgsqlRest": "Off", "NpgsqlRestClient": "Off", "NpgsqlRestTest": "Verbose" } } +{ + "Log": { "MinimalLevels": { "NpgsqlRest": "Off", "NpgsqlRestClient": "Off", "NpgsqlRestTest": "Verbose" } } +} ``` ### Configuration reference (`TestRunner` section) ```jsonc -"TestRunner": { - "FilePattern": "", // glob selecting test files (same engine as SqlFileSource); empty disables - "Filter": "", // narrow the discovered set: substring, or glob when it contains wildcards - "Tag": "", // run only files carrying at least one of these tags (-- @tag name ...) - "ExcludeTag": "", // skip files carrying any of these tags (wins over Tag) - "ConnectionName": "", // ConnectionStrings entry to test against; empty = the main connection - "MaxParallelism": 0, // concurrent test files; 0 = processor count - "FailFast": false, // stop scheduling new files after the first failure (in-flight finish) - "PerTestTimeout": "30s", // per-file timeout: "30s", "5m", "1h", plain seconds, "hh:mm:ss"; 0 disables - "JUnitOutput": null, // optional path for a JUnit XML report - "Keep": false, // skip Teardown (inspect state after a failed run) - "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests - "AllowEmpty": false, // exit 0 instead of 4 when no tests are found - "Watch": false, // watch mode (CLI: --watch): re-run on file changes until Ctrl+C; teardown on exit - "Coverage": null, // coverage summary: null (default) = on for full runs, quiet when narrowed; true/false = always/never - "CoverageThreshold": null, // 0-100: always report + fail an otherwise-passing run (exit 2) below it - "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) - "ResponseTempTable": { - "Name": "_response", // table name when a file has ONE HTTP block - "MultiNamePattern": "_response_{n}",// name pattern for 2+ blocks; {n} = 1-based block position - "Columns": { // response → column mapping; null/empty omits the column - "Status": "status", "Body": "body", "ContentType": "content_type", - "Headers": "headers", "IsSuccess": "is_success" - } - }, - "Steps": {}, // named, reusable steps (name → step) for Setup/Teardown and -- @setup/-- @teardown - "Setup": [], // run-once, BEFORE endpoint discovery, in written order (step names or inline objects) - "Teardown": [] // run-once, ALWAYS, in written order (Keep skips; same entries as Setup) +{ + "TestRunner": { + "FilePattern": "", // glob selecting test files (same engine as SqlFileSource); empty disables + "Filter": "", // narrow the discovered set: substring, or glob when it contains wildcards + "Tag": "", // run only files carrying at least one of these tags (-- @tag name ...) + "ExcludeTag": "", // skip files carrying any of these tags (wins over Tag) + "ConnectionName": "", // ConnectionStrings entry to test against; empty = the main connection + "MaxParallelism": 0, // concurrent test files; 0 = processor count + "FailFast": false, // stop scheduling new files after the first failure (in-flight finish) + "PerTestTimeout": "30s", // per-file timeout: "30s", "5m", "1h", plain seconds, "hh:mm:ss"; 0 disables + "JUnitOutput": null, // optional path for a JUnit XML report + "Keep": false, // skip Teardown (inspect state after a failed run) + "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests + "AllowEmpty": false, // exit 0 instead of 4 when no tests are found + "Watch": false, // watch mode (CLI: --watch): re-run on file changes until Ctrl+C; teardown on exit + "Coverage": null, // coverage summary: null (default) = on for full runs, quiet when narrowed; true/false = always/never + "CoverageThreshold": null, // 0-100: always report + fail an otherwise-passing run (exit 2) below it + "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) + "ResponseTempTable": { + "Name": "_response", // table name when a file has ONE HTTP block + "MultiNamePattern": "_response_{n}",// name pattern for 2+ blocks; {n} = 1-based block position + "Columns": { // response → column mapping; null/empty omits the column + "Status": "status", "Body": "body", "ContentType": "content_type", + "Headers": "headers", "IsSuccess": "is_success" + } + }, + "Steps": {}, // named, reusable steps (name → step) for Setup/Teardown and -- @setup/-- @teardown + "Setup": [], // run-once, BEFORE endpoint discovery, in written order (step names or inline objects) + "Teardown": [] // run-once, ALWAYS, in written order (Keep skips; same entries as Setup) + } } ``` @@ -384,7 +394,43 @@ What you get: **Why not `?` (JDBC style)?** Considered and rejected: `?`, `?|`, `?&`, and `@?` are PostgreSQL's own jsonb/geometric operators — `where data ? 'admin'` is legal, common SQL that no rewriter can reliably tell apart from a parameter. This is the same reason the PostgreSQL JDBC driver requires `??` escapes. Anonymous-positional already exists as `$N`. -## 4. Mute an individual logger with `"Off"` in `Log:MinimalLevels` +## 4. Watch mode: `--watch` + +The `--watch` flag runs in one of **two modes**, depending on whether `--test` is present: + +| Command | Mode | Watches | On change | +|---|---|---|---| +| `npgsqlrest ... --test --watch` | **Test watch** | test files, included fixtures/profiles, and — when the SQL file source is enabled — the endpoint SQL files | changed test re-runs alone; endpoint change rebuilds endpoints in-process and re-runs everything | +| `npgsqlrest ... --watch` | **Server watch** | the SQL file source tree and the configuration files | the server restarts (~1s) | + +Test watch works with or without a SQL file source (database-routine endpoints are testable too — they are just not watchable) and is described in the test runner section above. Server watch requires an **enabled SQL file source** — `--watch` without it (and without `--test`) has nothing to watch and exits with an error. + +### Server watch + +```sh +npgsqlrest ./config.json --watch +``` + +Run the **server** under a watcher: edit a SQL file and the running API restarts with the change applied (~1s) — add an endpoint and it's immediately callable, break one and the error is on screen while the rest of the API keeps serving, and any configured code generation (TypeScript client, HTTP files, OpenAPI) regenerates on every restart, so the frontend's types follow your SQL as you type. + +**How it works.** The process becomes a small **supervisor** that spawns itself as a child server (marked by an environment variable) and watches the `SqlFileSource` tree plus the **configuration files themselves**. On a debounced change it stops the child gracefully and starts a fresh one — the child runs the completely normal server pipeline, so dev is byte-for-byte production behavior (the same model as `dotnet watch`). One relaxation: in the watch child, `SqlFileSource.ErrorMode` is forced from `Exit` to `Skip`, so a broken file logs its error and drops only its own endpoint instead of taking the server down. + +Behavior: + +| Event | Result | +|---|---| +| `.sql` change under the source tree | restart (files matching `SkipPattern` — test files — are ignored) | +| configuration file change | restart with the new configuration | +| broken SQL file | restart; the error is logged, that endpoint drops, everything else serves | +| child crashes/exits on its own | supervisor prints `server exited (code N) — waiting for file changes` and revives on the next save (no crash-looping) | +| Ctrl+C / SIGTERM (`docker stop`) | child stopped gracefully, both processes exit, port freed | +| supervisor killed hard (SIGKILL) | the child detects the vanished parent and exits by itself — no orphan holding the port | + +Graceful child stop uses SIGTERM on Linux/macOS; on Windows the child is hard-killed (nothing needs teardown in a dev server). For environments where file events don't cross the filesystem boundary — Docker Desktop bind mounts, network shares — set the ecosystem-standard `DOTNET_USE_POLLING_FILE_WATCHER=1` to switch to a 1-second polling scan (applies to both watch modes). + +Works in every distribution: the AOT executables (the supervisor respawns `Environment.ProcessPath`), framework-dependent `dotnet NpgsqlRestClient.dll` (the dotnet host is re-invoked with the dll), and both Docker image flavors (the supervisor handles PID-1 signal and child-reaping duties). + +## 5. Mute an individual logger with `"Off"` in `Log:MinimalLevels` Each entry under **`Log:MinimalLevels`** now accepts **`"Off"`** (aliases **`"None"`** and **`"Silent"`**, case-insensitive) to **fully silence** that logger. Previously the only accepted values were the Serilog levels `Verbose…Fatal`, and there was no way to turn a logger off completely. @@ -394,11 +440,13 @@ Each entry under **`Log:MinimalLevels`** now accepts **`"Off"`** (aliases **`"No Each named logger is controlled independently — e.g. mute the application loggers entirely while watching the test runner: ```json -"Log": { - "MinimalLevels": { - "NpgsqlRest": "Off", - "NpgsqlRestClient": "Off", - "NpgsqlRestTest": "Verbose" +{ + "Log": { + "MinimalLevels": { + "NpgsqlRest": "Off", + "NpgsqlRestClient": "Off", + "NpgsqlRestTest": "Verbose" + } } } ``` From ba18c7493b339b800e8174ac1d317009e3aa924f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 19:40:52 +0200 Subject: [PATCH 10/14] feat(watch): database routine polling + one Watch config section for both flavors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch mode now watches ALL endpoint sources in BOTH flavors. Routine-source endpoints (functions/procedures) have no files to watch, so watch mode polls the DATABASE — with perfect fidelity: RoutineSource.CreateFingerprintCommand runs the SAME discovery query the endpoint source uses (same Query property, same ten configured filter parameters — refactored into a shared command builder used by Read() too), wrapped in an order-independent server-side hash (select sum(hashtextextended(q::text,0)) from () q). If the hash changes, the discovered endpoints changed — by definition: create/replace/ drop/alter of functions and procedures (function bodies are in the result via pg_get_functiondef), GRANT/REVOKE, COMMENT ON (annotations), and changes to the composite types and tables used as parameter/return types. Anything the discovery query does not read — unrelated tables, temp objects, data — can never cause a spurious trigger. - WatchDbPoller (client): polls on a dedicated non-pooled connection, takes the actual configured RoutineSource instances from EndpointSources; skips cleanly when there are none. Rebaseline() prevents self-triggering. - Server watch: the child polls and exits with code 64 -> the supervisor respawns immediately ("database change detected — restarting"). Supervisor loop reworked so the pending change batch survives respawn iterations (no competing channel readers). Routines-only projects are now watchable: --watch is valid without a SQL file source when polling is active. - Test watch: the poller feeds a sentinel into the existing change channel -> endpoint rebuild + full rerun ("change detected (database)"); the runner re-baselines after every rerun so committed fixtures never re-trigger. Config consolidated into ONE top-level Watch section (TestRunner.Watch REMOVED — it was the same word with a different type and role): "Watch": { "Enabled": false, "DatabasePollingInterval": "2s" } --watch is the CLI shorthand for Watch:Enabled (--watch:enabled=true also works); the flavor is picked by --test. Polling accepts "2s"/"500ms"/"1m"/ seconds/hh:mm:ss, 0 disables, and deactivates automatically when RoutineOptions.Enabled is false. Config synced x4 + schema descriptions. Tests: WatchDbFingerprintTests proves the hash tracks the discovery result exactly (create/replace/comment/drop and used-type changes fire; temp objects and unrelated tables never do; nameSimilarTo filter wiring verified). Full suite green (2394). Live-verified: function created in psql serving ~2s later on a routines-only watch; replace -> new body; drop -> 404; alter table add column reshaping a `returns setof` endpoint ([{"id":1}] -> [{"id":1,"name":"ada"}]); unrelated table create/drop -> zero restarts; test-watch database rerun without loops; all four activation paths (server/test x flag/config). Changelog: watch section finalized. --- NpgsqlRest/RoutineSource.cs | 61 ++++++---- NpgsqlRestClient/Builder.cs | 5 +- NpgsqlRestClient/ConfigDefaults.cs | 6 +- NpgsqlRestClient/ConfigSchemaGenerator.cs | 3 +- NpgsqlRestClient/ConfigTemplate.cs | 34 ++++-- NpgsqlRestClient/Program.cs | 36 ++++-- NpgsqlRestClient/Testing/TestRunner.cs | 31 ++++- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 10 +- NpgsqlRestClient/WatchDbPoller.cs | 106 ++++++++++++++++++ NpgsqlRestClient/WatchSupervisor.cs | 96 ++++++++++++---- NpgsqlRestClient/appsettings.json | 34 ++++-- NpgsqlRestTests/WatchDbFingerprintTests.cs | 75 +++++++++++++ changelog/v3.19.0.md | 33 ++++-- 13 files changed, 440 insertions(+), 90 deletions(-) create mode 100644 NpgsqlRestClient/WatchDbPoller.cs create mode 100644 NpgsqlRestTests/WatchDbFingerprintTests.cs diff --git a/NpgsqlRest/RoutineSource.cs b/NpgsqlRest/RoutineSource.cs index 09d8b1e8..20c2e649 100644 --- a/NpgsqlRest/RoutineSource.cs +++ b/NpgsqlRest/RoutineSource.cs @@ -45,6 +45,44 @@ public class RoutineSource( /// public bool ResolveNestedCompositeTypes { get; set; } = resolveNestedCompositeTypes; + /// + /// The discovery command (query text + the ten filter parameters, each resolved against this source + /// or the global options) — shared by and . + /// With the SAME query is wrapped in an order-independent server-side + /// hash of its result rows: if the hash changes, the discovered endpoints changed — by definition. + /// + private NpgsqlCommand CreateSourceCommand(NpgsqlConnection connection, bool fingerprint) + { + var command = connection.CreateCommand(); + Query ??= RoutineSourceQuery.Query; + var text = Query.Contains(Consts.Space) is false + ? string.Concat("select * from ", Query, "($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)") + : Query; + command.CommandText = fingerprint + ? string.Concat("select coalesce(sum(hashtextextended(q::text, 0)), 0)::text from (", text, ") q") + : text; + + command.AddParameter(SchemaSimilarTo ?? Options.SchemaSimilarTo); // $1 + command.AddParameter(SchemaNotSimilarTo ?? Options.SchemaNotSimilarTo); // $2 + command.AddParameter(IncludeSchemas ?? Options.IncludeSchemas, true); // $3 + command.AddParameter(ExcludeSchemas ?? Options.ExcludeSchemas, true); // $4 + command.AddParameter(NameSimilarTo ?? Options.NameSimilarTo); // $5 + command.AddParameter(NameNotSimilarTo ?? Options.NameNotSimilarTo); // $6 + command.AddParameter(IncludeNames ?? Options.IncludeNames, true); // $7 + command.AddParameter(ExcludeNames ?? Options.ExcludeNames, true); // $8 + command.AddParameter(IncludeLanguages, true); // $9 + command.AddParameter(ExcludeLanguages is null ? ["c", "internal"] : ExcludeLanguages, true); // $10 + return command; + } + + /// + /// A command returning one text value: a hash of this source's ENTIRE discovery result (same query, + /// same configured filters). Watch mode polls it to detect any change to the discovered endpoints — + /// routines, their comments/annotations, and the composite/table types their signatures use. + /// + public NpgsqlCommand CreateFingerprintCommand(NpgsqlConnection connection) + => CreateSourceCommand(connection, fingerprint: true); + public IEnumerable<(Routine, IRoutineSourceParameterFormatter)> Read(IServiceProvider? serviceProvider, RetryStrategy? retryStrategy) { bool shouldDispose = true; @@ -64,28 +102,7 @@ public class RoutineSource( CompositeTypeCache.Initialize(connection, Options.NameConverter); } - using var command = connection.CreateCommand(); - Query ??= RoutineSourceQuery.Query; - if (Query.Contains(Consts.Space) is false) - { - command.CommandText = string.Concat("select * from ", Query, "($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)"); - } - else - { - command.CommandText = Query; - } - - command.AddParameter(SchemaSimilarTo ?? Options.SchemaSimilarTo); // $1 - command.AddParameter(SchemaNotSimilarTo ?? Options.SchemaNotSimilarTo); // $2 - command.AddParameter(IncludeSchemas ?? Options.IncludeSchemas, true); // $3 - command.AddParameter(ExcludeSchemas ?? Options.ExcludeSchemas, true); // $4 - command.AddParameter(NameSimilarTo ?? Options.NameSimilarTo); // $5 - command.AddParameter(NameNotSimilarTo ?? Options.NameNotSimilarTo); // $6 - command.AddParameter(IncludeNames ?? Options.IncludeNames, true); // $7 - command.AddParameter(ExcludeNames ?? Options.ExcludeNames, true); // $8 - command.AddParameter(IncludeLanguages, true); // $9 - command.AddParameter(ExcludeLanguages is null ? ["c", "internal"] : ExcludeLanguages, true); // $10 - + using var command = CreateSourceCommand(connection, fingerprint: false); command.LogCommand(nameof(RoutineSource)); using NpgsqlDataReader reader = command.ExecuteReaderWithRetry(retryStrategy); while (reader.Read()) diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index a2590046..8f1c8dbd 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3075,11 +3075,12 @@ public TestRunnerOptions BuildTestRunnerOptions() opt.MaxParallelism = _config.GetConfigInt("MaxParallelism", section) ?? 0; opt.FailFast = _config.GetConfigBool("FailFast", section, false); opt.PerTestTimeout = ParseTestTimeout(_config.GetConfigStr("PerTestTimeout", section), TimeSpan.FromSeconds(30)); + opt.DatabasePollingInterval = ParseTestTimeout( + _config.GetConfigStr("DatabasePollingInterval", _config.Cfg.GetSection("Watch")), TimeSpan.FromSeconds(2)); opt.JUnitOutput = _config.GetConfigStr("JUnitOutput", section); opt.Keep = _config.GetConfigBool("Keep", section, false); opt.DetailedReport = _config.GetConfigBool("DetailedReport", section, false); opt.AllowEmpty = _config.GetConfigBool("AllowEmpty", section, false); - opt.Watch = _config.GetConfigBool("Watch", section, false); opt.CoverageThreshold = _config.GetConfigInt("CoverageThreshold", section); // Tri-state: absent/empty => null (report after full runs only); explicit true/false honored. if (string.IsNullOrEmpty(section.GetSection("Coverage").Value) is false) @@ -3179,7 +3180,7 @@ List ReadTestSteps(IConfigurationSection stepsSection) } } - private static TimeSpan ParseTestTimeout(string? raw, TimeSpan def) + internal static TimeSpan ParseTestTimeout(string? raw, TimeSpan def) { if (string.IsNullOrWhiteSpace(raw)) return def; raw = raw.Trim(); diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 8ccc6117..537fb66e 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -174,6 +174,11 @@ public static JsonObject GetDefaults() ["HealthChecks"] = GetHealthChecksDefaults(), ["Stats"] = GetStatsDefaults(), ["TestRunner"] = GetTestRunnerDefaults(), + ["Watch"] = new JsonObject + { + ["Enabled"] = false, + ["DatabasePollingInterval"] = "2s", + }, ["CommandRetryOptions"] = GetCommandRetryOptionsDefaults(), ["CacheOptions"] = GetCacheOptionsDefaults(), ["ValidationOptions"] = GetValidationOptionsDefaults(), @@ -1112,7 +1117,6 @@ private static JsonObject GetTestRunnerDefaults() ["Keep"] = false, ["DetailedReport"] = false, ["AllowEmpty"] = false, - ["Watch"] = false, ["Coverage"] = null, ["CoverageThreshold"] = null, ["LoggerName"] = "NpgsqlRestTest", diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 163cccdb..4636a6f3 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -259,9 +259,10 @@ public static partial class ConfigSchemaGenerator ["TestRunner:Keep"] = "Skip Teardown so a failed run's state can be inspected.", ["TestRunner:DetailedReport"] = "Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show captured `raise notice` output for passing tests too.\nThis shapes the report only — for diagnostic logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName).", ["TestRunner:AllowEmpty"] = "Treat \"no tests discovered\" as success (exit 0) instead of exit 4.", - ["TestRunner:Watch"] = "Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file changes until Ctrl+C.\nA changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a broken file cannot kill the session) and re-runs everything; any other changed .sql under the test tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop exits 0 (not for CI gating).", ["TestRunner:Coverage"] = "Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded from the ratio and counted separately).\nTri-state: null (default) reports after FULL runs but stays quiet when the run is narrowed by Filter/Tag; true always reports; false never.", ["TestRunner:CoverageThreshold"] = "Fail an otherwise-passing run with exit 2 when endpoint coverage is below this percentage (0-100) — CI gating for \"every endpoint has a test\". Always reports coverage when set, regardless of the Coverage setting or run narrowing. Null = no gate.", + ["Watch:Enabled"] = "Turn watch mode on (interactive/dev-only; the --watch command line flag is the shorthand for this setting).\nWith --test: re-run tests on changes (changed test file re-runs alone; changed endpoint file or database routine rebuilds endpoints in-process and re-runs everything; teardown once, on exit). Without --test: supervise the server and restart it on SQL file source, configuration, and database routine changes.", + ["Watch:DatabasePollingInterval"] = "Watch mode (both --watch flavors): poll the database for routine changes and restart the server (server watch) or rebuild endpoints and re-run tests (test watch).\nRuns the SAME routine discovery query the endpoint source uses (same configured filters), hashed server-side — detecting exactly what changes discovered endpoints: functions/procedures, grants, COMMENT ON annotations, and the composite/table types their signatures use. Accepts \"2s\", \"500ms\", \"1m\", plain seconds, or \"hh:mm:ss\"; 0 disables.", ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 4ec7f671..18119364 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1502,15 +1502,6 @@ public static partial class ConfigSchemaGenerator // "AllowEmpty": false, // - // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file - // changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource - // FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a - // broken file cannot kill the session) and re-runs everything; any other changed .sql under the test - // tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop - // exits 0 (not for CI gating). - // - "Watch": false, - // // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded // from the ratio and counted separately). Tri-state: null (default) reports after FULL runs but stays @@ -1569,6 +1560,31 @@ public static partial class ConfigSchemaGenerator "Teardown": [] }, + // + // Watch mode (interactive/dev-only) — one feature, two flavors: with --test it re-runs tests on + // changes (a changed test file re-runs alone; a changed endpoint file or database routine rebuilds + // endpoints in-process and re-runs everything; teardown runs once, on exit); without --test it + // supervises the SERVER and restarts it on SQL file source, configuration, and database routine + // changes. In both flavors a broken SQL file cannot kill the session (SqlFileSource ErrorMode is + // forced from Exit to Skip while watching). + // + "Watch": { + // + // Turn watch mode on. The --watch command line flag is the shorthand for this setting. + // + "Enabled": false, + // + // Poll the database for routine changes and restart the server (server watch) or rebuild endpoints and + // re-run the tests (test watch). The poll runs the SAME routine discovery query the endpoint source + // uses (same configured filters), hashed server-side into one value — so it detects exactly what + // changes discovered endpoints: functions/procedures (create/replace/drop/alter, grants), their + // COMMENT ON annotations, and the composite types and tables their signatures use; anything the + // discovery does not read can never trigger. Accepts "2s", "500ms", "1m", a plain number of seconds, + // or "hh:mm:ss"; 0 disables. One query per interval on a dedicated non-pooled connection. + // + "DatabasePollingInterval": "2s" + }, + // // Command retry strategies and options for client and middleware commands. // diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index 641524ab..a7b46c66 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -272,15 +272,17 @@ bool endpointsMode = args.Any(a => string.Equals(a, "--endpoints", StringComparison.OrdinalIgnoreCase)); bool testMode = args.Any(a => string.Equals(a, "--test", StringComparison.OrdinalIgnoreCase)); -// Watch mode (interactive re-run loop). Needed this early because it relaxes SqlFileSource error handling -// (a broken endpoint file must not kill the watch session — see CreateEndpointSources). -bool watchMode = testMode && (args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) - || config.GetConfigBool("Watch", config.Cfg.GetSection("TestRunner"))); - -// Server watch mode (--watch WITHOUT --test): this process becomes a supervisor that spawns itself as +// Watch mode — one feature, two flavors chosen by --test. The --watch flag is shorthand for +// Watch:Enabled. Needed this early because it relaxes SqlFileSource error handling (a broken endpoint +// file must not kill the watch session — see CreateEndpointSources). +bool watchRequested = args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase)) + || config.GetConfigBool("Enabled", config.Cfg.GetSection("Watch")); +bool watchMode = testMode && watchRequested; + +// Server watch (--watch WITHOUT --test): this process becomes a supervisor that spawns itself as // a child server and restarts it on SQL/config file changes; the child (marked by an env variable) // falls through and runs the normal server pipeline. See WatchSupervisor. -if (testMode is false && args.Any(a => string.Equals(a, "--watch", StringComparison.OrdinalIgnoreCase))) +if (testMode is false && watchRequested) { if (WatchSupervisor.IsChild is false) { @@ -733,4 +735,24 @@ } } +// Server watch child: poll the routine discovery query (hashed server-side, same configured filters) +// and request a restart (exit code 64 -> the supervisor respawns immediately) when its result changes — +// routines, their comments (annotations), and the types their signatures use are invisible to a file watcher. +if (WatchSupervisor.IsChild) +{ + var dbPollInterval = NpgsqlRestClient.Builder.ParseTestTimeout( + config.GetConfigStr("DatabasePollingInterval", config.Cfg.GetSection("Watch")), TimeSpan.FromSeconds(2)); + var routineSources = options.EndpointSources.OfType().ToArray(); + if (dbPollInterval > TimeSpan.Zero && routineSources.Length > 0) + { + var dbPoller = new WatchDbPoller(connectionString!, dbPollInterval, onChange: () => + { + builder.ClientLogger?.LogInformation("watch: database change detected — restarting"); + Environment.ExitCode = WatchSupervisor.DbChangeExitCode; + app.Lifetime.StopApplication(); + }, builder.ClientLogger, routineSources); + _ = dbPoller.RunAsync(app.Lifetime.ApplicationStopping); + } +} + app.Run(); diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 23c1c237..94729fa9 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -25,6 +25,9 @@ public sealed class TestRunner // Indentation is written OUTSIDE the style so the grey block starts at the text. Escapes are emitted // only on a real terminal (see Out). public const string AnsiFail = "\x1b[38;5;197m\x1b[48;5;238m"; + + // Channel sentinel written by the database poller (not a real path — NUL is invalid in paths). + private const string DbChangeSentinel = "\u0000database"; private const string AnsiOk = "\x1b[38;5;47m\x1b[48;5;238m"; private const string AnsiPlain = "\x1b[0m"; @@ -455,10 +458,23 @@ FileSystemWatcher NewWatcher(string dir) && !endpointBaseFull.StartsWith(testBaseFull, StringComparison.Ordinal); using var endpointWatcher = separateEndpointTree ? NewWatcher(endpointBaseFull!) : null; + // Database polling: routine changes (create/replace/drop/comment, signature types) have no + // file to watch — a poller on the test connection runs the routine discovery query hashed + // server-side and feeds a sentinel into the same channel, triggering a rebuild + full rerun. + WatchDbPoller? dbPoller = null; + var routineSources = _rest.EndpointSources.OfType().ToArray(); + if (_opt.DatabasePollingInterval > TimeSpan.Zero && routineSources.Length > 0) + { + dbPoller = new WatchDbPoller(_connString, _opt.DatabasePollingInterval, + onChange: () => changes.Writer.TryWrite(DbChangeSentinel), _log, routineSources); + _ = dbPoller.RunAsync(stop.Token); + } + var watching = Path.GetRelativePath(Environment.CurrentDirectory, testBaseFull) + (endpointBaseFull is not null && endpointBaseFull != testBaseFull ? $" + {Path.GetRelativePath(Environment.CurrentDirectory, endpointBaseFull)} (endpoints)" - : ""); + : "") + + (dbPoller is not null ? $" + database (poll {_opt.DatabasePollingInterval.TotalSeconds:0.#}s)" : ""); _out.Line($"\nwatching {watching} for changes — Ctrl+C to stop", ConsoleColor.Cyan); while (!stop.IsCancellationRequested) @@ -474,6 +490,12 @@ FileSystemWatcher NewWatcher(string dir) bool rebuild = false; foreach (var path in changed) { + if (string.Equals(path, DbChangeSentinel, StringComparison.Ordinal)) + { + // Routines changed in the database: re-describe (rebuild) and re-run everything. + if (rebuildEndpoints is not null) rebuild = true; else runAll = true; + continue; + } var rel = Path.GetRelativePath(Environment.CurrentDirectory, path).Replace('\\', '/'); if (MatchesCwdRelativePattern(rel, _opt.FilePattern)) { @@ -491,7 +513,9 @@ FileSystemWatcher NewWatcher(string dir) } if (!rebuild && !runAll && testFiles.Count == 0) continue; // e.g. only deletions - var trigger = Path.GetRelativePath(Environment.CurrentDirectory, changed[0]); + var trigger = string.Equals(changed[0], DbChangeSentinel, StringComparison.Ordinal) + ? "database" + : Path.GetRelativePath(Environment.CurrentDirectory, changed[0]); _out.Line($"\n— {DateTime.Now:HH:mm:ss} change detected ({trigger}{(changed.Count > 1 ? $" +{changed.Count - 1}" : "")}) —", ConsoleColor.Cyan); if (rebuild) @@ -516,6 +540,9 @@ FileSystemWatcher NewWatcher(string dir) } await ExecuteDiscoveredFilesAsync(endpoints, lookup, only: runAll ? null : testFiles.Distinct().ToList(), stop.Token); + // The rerun itself may have changed the database (committed fixtures, per-file steps) — + // re-baseline so self-inflicted changes never trigger another rerun. + dbPoller?.Rebaseline(); _out.Line($"\nwatching — Ctrl+C to stop", ConsoleColor.Cyan); } return ExitPass; diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 014dbd3f..e1ff374a 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -75,13 +75,11 @@ public class TestRunnerOptions public bool AllowEmpty { get; set; } = false; /// - /// Watch mode (interactive/dev-only; CLI: --watch): run everything once, then re-run on file - /// changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource - /// FilePattern) rebuilds the endpoints in-process (delta reported) and re-runs everything; any other - /// changed .sql under the test tree re-runs everything (an included fixture's dependents are unknown). - /// Teardown runs once, on exit; graceful exit code is 0. + /// Watch mode only: poll the test database's catalog for routine changes (create/replace/drop/alter + /// of functions/procedures, COMMENT ON) and rebuild endpoints + re-run everything when it changes. + /// Read from the shared top-level Watch:DatabasePollingInterval setting; zero disables. /// - public bool Watch { get; set; } = false; + public TimeSpan DatabasePollingInterval { get; set; } = TimeSpan.FromSeconds(2); /// /// SourceContext name for the runner's own log channel — discovery/parsing at Debug, each query and HTTP diff --git a/NpgsqlRestClient/WatchDbPoller.cs b/NpgsqlRestClient/WatchDbPoller.cs new file mode 100644 index 00000000..b36f6230 --- /dev/null +++ b/NpgsqlRestClient/WatchDbPoller.cs @@ -0,0 +1,106 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlRest; + +namespace NpgsqlRestClient; + +/// +/// Database change detection for watch mode. There is no filesystem to watch for routine-source +/// endpoints — instead, the poller runs the SAME discovery query the routine source uses (same +/// configured filters — see ), hashed server-side +/// into one scalar, on a dedicated non-pooled connection. If the hash changes, the discovered +/// endpoints changed — by definition: create/replace/drop/alter of functions and procedures, grants, +/// COMMENT ON (annotations), schema renames, and changes to the composite/table types their signatures +/// use. Anything the query does not read (an unrelated table, temp objects, data) can never trigger. +/// +public sealed class WatchDbPoller( + string connectionString, + TimeSpan interval, + Action onChange, + ILogger? logger, + IReadOnlyList sources) +{ + private readonly string _connString = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }.ConnectionString; + private NpgsqlConnection? _conn; + private string? _baseline; + private volatile bool _rebaseline; + + /// Fingerprint of one source's discovery result on an open connection (also used by tests). + public static string? GetFingerprint(NpgsqlConnection connection, RoutineSource source) + { + using var cmd = source.CreateFingerprintCommand(connection); + return cmd.ExecuteScalar() as string; + } + + /// + /// Forget the baseline so the next tick captures a fresh one WITHOUT firing. Called after work + /// that legitimately changes the database (a test rerun with committed fixtures, a restart) so + /// self-inflicted changes never trigger. + /// + public void Rebaseline() => _rebaseline = true; + + /// Poll loop; runs until cancelled. Connection failures skip the tick and retry. + public async Task RunAsync(CancellationToken ct) + { + if (sources.Count == 0) + { + return; + } + while (ct.IsCancellationRequested is false) + { + try + { + await Task.Delay(interval, ct); + } + catch (OperationCanceledException) + { + break; + } + try + { + if (_conn is null || _conn.State != System.Data.ConnectionState.Open) + { + _conn?.Dispose(); + _conn = new NpgsqlConnection(_connString); + await _conn.OpenAsync(ct); + } + var sb = new StringBuilder(); + foreach (var source in sources) + { + await using var cmd = source.CreateFingerprintCommand(_conn); + sb.Append(await cmd.ExecuteScalarAsync(ct) as string).Append('|'); + } + var current = sb.ToString(); + if (_rebaseline) + { + _rebaseline = false; + _baseline = current; + continue; + } + if (_baseline is null) + { + _baseline = current; + continue; + } + if (string.Equals(_baseline, current, StringComparison.Ordinal) is false) + { + _baseline = current; + onChange(); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + // DB down / restarting: skip this tick, reconnect on the next one. + logger?.LogDebug("watch: database poll failed ({Message}) — retrying", ex.Message); + try { _conn?.Dispose(); } catch { } + _conn = null; + } + } + try { _conn?.Dispose(); } catch { } + } +} diff --git a/NpgsqlRestClient/WatchSupervisor.cs b/NpgsqlRestClient/WatchSupervisor.cs index 829caa9b..c93ebaf6 100644 --- a/NpgsqlRestClient/WatchSupervisor.cs +++ b/NpgsqlRestClient/WatchSupervisor.cs @@ -31,6 +31,9 @@ public static class WatchSupervisor public static bool IsChild { get; } = string.IsNullOrEmpty(Environment.GetEnvironmentVariable(ChildEnv)) is false; + /// Child exit code meaning "the database poller detected a change — respawn me immediately". + public const int DbChangeExitCode = 64; + // Blittable signature — plain DllImport is AOT-safe here and avoids AllowUnsafeBlocks. [DllImport("libc", SetLastError = true, EntryPoint = "kill")] private static extern int kill(int pid, int sig); @@ -70,31 +73,47 @@ public static async Task RunAsync(Config config, string[] args) { var output = new Out(); - // Something to watch? Server watch requires an enabled SqlFileSource (database routines have no - // files to watch — a change in the database needs a manual restart either way). + // Something to watch? A SqlFileSource tree, database catalog polling, or both. var sqlFileCfg = config.NpgsqlRestCfg.GetSection("SqlFileSource"); string? filePattern = null; if (sqlFileCfg.Exists() && config.GetConfigBool("Enabled", sqlFileCfg, true)) { filePattern = config.GetConfigStr("FilePattern", sqlFileCfg); } - if (string.IsNullOrWhiteSpace(filePattern)) + // Database polling (the child does the actual polling by hashing the routine discovery query; + // the supervisor needs the value for validation and the banner). With polling active, --watch is + // valid even without a SQL file source — a routines-only project restarts on create/replace/drop/ + // comment. Polling is only meaningful when the routine source itself is enabled. + var dbPollInterval = Builder.ParseTestTimeout( + config.GetConfigStr("DatabasePollingInterval", config.Cfg.GetSection("Watch")), TimeSpan.FromSeconds(2)); + var routineOptionsCfg = config.NpgsqlRestCfg.GetSection("RoutineOptions"); + if (routineOptionsCfg.Exists() && config.GetConfigBool("Enabled", routineOptionsCfg, true) is false) { - output.LineAnsi( - "watch: nothing to watch — --watch without --test requires an enabled SqlFileSource with a FilePattern. For test watch mode use --test --watch.", - Testing.TestRunner.AnsiFail); - return 1; + dbPollInterval = TimeSpan.Zero; + } + + string? baseDirFull = null; + var skipPattern = "*.test.sql"; + if (string.IsNullOrWhiteSpace(filePattern) is false) + { + var baseDir = WatchUtils.GetWatchBaseDir(filePattern); + if (baseDir is null) + { + output.LineAnsi( + $"watch: cannot watch — SqlFileSource FilePattern \"{filePattern}\" has no existing base directory.", + Testing.TestRunner.AnsiFail); + return 1; + } + baseDirFull = Path.GetFullPath(baseDir); + skipPattern = (config.GetConfigStr("SkipPattern", sqlFileCfg) ?? "*.test.sql").Replace('\\', '/'); } - var baseDir = WatchUtils.GetWatchBaseDir(filePattern); - if (baseDir is null) + else if (dbPollInterval <= TimeSpan.Zero) { output.LineAnsi( - $"watch: cannot watch — SqlFileSource FilePattern \"{filePattern}\" has no existing base directory.", + "watch: nothing to watch — --watch without --test requires an enabled SqlFileSource with a FilePattern and/or database polling (Watch:DatabasePollingInterval). For test watch mode use --test --watch.", Testing.TestRunner.AnsiFail); return 1; } - var baseDirFull = Path.GetFullPath(baseDir); - var skipPattern = (config.GetConfigStr("SkipPattern", sqlFileCfg) ?? "*.test.sql").Replace('\\', '/'); // Config files restart the server too: the .json/.jsonc files passed on the command line, plus // the implicit default when present. @@ -130,11 +149,14 @@ void OnSignal(PosixSignalContext ctx) bool polling = WatchUtils.UsePollingWatcher; if (polling) { - WatchUtils.StartPollingWatcher(baseDirFull, configFiles, changes.Writer, stop.Token); + WatchUtils.StartPollingWatcher(baseDirFull ?? "", configFiles, changes.Writer, stop.Token); } else { - watchers.Add(WatchUtils.NewSqlWatcher(baseDirFull, changes.Writer)); + if (baseDirFull is not null) + { + watchers.Add(WatchUtils.NewSqlWatcher(baseDirFull, changes.Writer)); + } foreach (var dir in configFiles.Select(Path.GetDirectoryName).Where(d => d is not null).Distinct(StringComparer.Ordinal)) { var w = new FileSystemWatcher(dir!) @@ -156,32 +178,56 @@ void OnFsEvent(object s, FileSystemEventArgs e) } } - var watchingWhat = $"{Path.GetRelativePath(Environment.CurrentDirectory, baseDirFull)} (*.sql)" - + (configFiles.Count > 0 ? " + config files" : "") - + (polling ? " [polling]" : ""); + var watchingParts = new List(3); + if (baseDirFull is not null) + { + watchingParts.Add($"{Path.GetRelativePath(Environment.CurrentDirectory, baseDirFull)} (*.sql)"); + } + if (configFiles.Count > 0) + { + watchingParts.Add("config files"); + } + if (dbPollInterval > TimeSpan.Zero) + { + watchingParts.Add($"database (poll {dbPollInterval.TotalSeconds:0.#}s)"); + } + var watchingWhat = string.Join(" + ", watchingParts) + (polling ? " [polling]" : ""); output.Line($"watch mode: supervising the server process, watching {watchingWhat} — Ctrl+C to stop", ConsoleColor.Cyan); Process child = Spawn(args); Task exitTask = child.WaitForExitAsync(CancellationToken.None); + // The pending batch survives iterations that don't consume it (e.g. a database-change respawn), + // so the channel never has two competing readers. + Task?>? pendingBatch = null; try { while (stopping is false) { - var batchTask = WatchUtils.NextChangeBatchAsync(changes.Reader, stop.Token); - var completed = await Task.WhenAny(exitTask, batchTask); + pendingBatch ??= WatchUtils.NextChangeBatchAsync(changes.Reader, stop.Token); + var completed = await Task.WhenAny(exitTask, pendingBatch); if (stopping) { break; } - if (completed == exitTask && batchTask.IsCompleted is false) + if (completed == exitTask && pendingBatch.IsCompleted is false) { + if (child.ExitCode == DbChangeExitCode) + { + // The child's database poller detected a routine/annotation change and asked to be + // restarted. + output.Line($"\n— {DateTime.Now:HH:mm:ss} database change detected — restarting —", ConsoleColor.Cyan); + child = Spawn(args); + exitTask = child.WaitForExitAsync(CancellationToken.None); + continue; + } // The child died on its own (crash, config error, port conflict). Don't respawn in a // loop — hold until the next file change, then try again. output.Line($"\nserver exited (code {child.ExitCode}) — waiting for file changes", ConsoleColor.Yellow); - var wakeBatch = await batchTask; + var wakeBatch = await pendingBatch; + pendingBatch = null; if (wakeBatch is null || stopping) { break; @@ -197,7 +243,8 @@ void OnFsEvent(object s, FileSystemEventArgs e) continue; } - var batch = await batchTask; + var batch = await pendingBatch; + pendingBatch = null; if (batch is null) { break; @@ -229,7 +276,7 @@ void OnFsEvent(object s, FileSystemEventArgs e) /// SqlFileSource FilePattern and does NOT match SkipPattern (a test-file edit must not bounce the /// server). /// - private static string? Relevant(List batch, string baseDirFull, string skipPattern, HashSet configFileSet) + private static string? Relevant(List batch, string? baseDirFull, string skipPattern, HashSet configFileSet) { foreach (var path in batch.Distinct(StringComparer.Ordinal)) { @@ -239,7 +286,8 @@ void OnFsEvent(object s, FileSystemEventArgs e) { return rel; } - if (full.StartsWith(baseDirFull, StringComparison.Ordinal) + if (baseDirFull is not null + && full.StartsWith(baseDirFull, StringComparison.Ordinal) && rel.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) && WatchUtils.MatchesCwdRelativePattern(rel, skipPattern) is false) { diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 5a8cccba..450ada4e 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1493,15 +1493,6 @@ // "AllowEmpty": false, // - // Watch mode (interactive/dev-only; CLI flag: --watch): run everything once, then re-run on file - // changes until Ctrl+C. A changed test file re-runs alone; a changed ENDPOINT file (SqlFileSource - // FilePattern) rebuilds the endpoints in-process (delta reported; ErrorMode forced Exit->Skip so a - // broken file cannot kill the session) and re-runs everything; any other changed .sql under the test - // tree (e.g. an included fixture) re-runs everything. Teardown runs once, on exit; a graceful stop - // exits 0 (not for CI gating). - // - "Watch": false, - // // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded // from the ratio and counted separately). Tri-state: null (default) reports after FULL runs but stays @@ -1560,6 +1551,31 @@ "Teardown": [] }, + // + // Watch mode (interactive/dev-only) — one feature, two flavors: with --test it re-runs tests on + // changes (a changed test file re-runs alone; a changed endpoint file or database routine rebuilds + // endpoints in-process and re-runs everything; teardown runs once, on exit); without --test it + // supervises the SERVER and restarts it on SQL file source, configuration, and database routine + // changes. In both flavors a broken SQL file cannot kill the session (SqlFileSource ErrorMode is + // forced from Exit to Skip while watching). + // + "Watch": { + // + // Turn watch mode on. The --watch command line flag is the shorthand for this setting. + // + "Enabled": false, + // + // Poll the database for routine changes and restart the server (server watch) or rebuild endpoints and + // re-run the tests (test watch). The poll runs the SAME routine discovery query the endpoint source + // uses (same configured filters), hashed server-side into one value — so it detects exactly what + // changes discovered endpoints: functions/procedures (create/replace/drop/alter, grants), their + // COMMENT ON annotations, and the composite types and tables their signatures use; anything the + // discovery does not read can never trigger. Accepts "2s", "500ms", "1m", a plain number of seconds, + // or "hh:mm:ss"; 0 disables. One query per interval on a dedicated non-pooled connection. + // + "DatabasePollingInterval": "2s" + }, + // // Command retry strategies and options for client and middleware commands. // diff --git a/NpgsqlRestTests/WatchDbFingerprintTests.cs b/NpgsqlRestTests/WatchDbFingerprintTests.cs new file mode 100644 index 00000000..cbb81929 --- /dev/null +++ b/NpgsqlRestTests/WatchDbFingerprintTests.cs @@ -0,0 +1,75 @@ +using Npgsql; +using NpgsqlRest; +using NpgsqlRestClient; + +namespace NpgsqlRestTests; + +[Collection("TestFixture")] +public class WatchDbFingerprintTests +{ + [Fact] + public void Fingerprint_TracksDiscoveryResult_Exactly() + { + using var conn = Database.CreateConnection(); + conn.Open(); + void Exec(string sql) + { + using var c = conn.CreateCommand(); + c.CommandText = sql; + c.ExecuteNonQuery(); + } + // The fingerprint is the routine discovery query itself, hashed server-side — so it tracks the + // discovered endpoints by definition, with the source's configured filters applied. + var source = new RoutineSource(nameSimilarTo: "watch\\_fp\\_%"); + string Fp() => WatchDbPoller.GetFingerprint(conn, source)!; + + Exec("drop function if exists watch_fp_test_fn(int)"); + Exec("drop table if exists watch_fp_typed cascade"); + var fp0 = Fp(); + fp0.Should().NotBeNullOrEmpty(); + + // create function → changes + Exec("create function watch_fp_test_fn(a int) returns int language sql as 'select a'"); + var fp1 = Fp(); + fp1.Should().NotBe(fp0, "creating a function must change the fingerprint"); + + // replace body → changes (the result includes pg_get_functiondef) + Exec("create or replace function watch_fp_test_fn(a int) returns int language sql as 'select a + 1'"); + var fp2 = Fp(); + fp2.Should().NotBe(fp1, "replacing a function body must change the fingerprint"); + + // comment = annotations → changes + Exec("comment on function watch_fp_test_fn(int) is 'HTTP GET'"); + var fp3 = Fp(); + fp3.Should().NotBe(fp2, "COMMENT ON a function (annotations) must change the fingerprint"); + + // temp table → must NOT change + Exec("create temp table watch_fp_tmp(id int)"); + var fp4 = Fp(); + fp4.Should().Be(fp3, "temp objects must not look like a database change"); + + // a table NOT used by any discovered routine → must NOT change (precision: the discovery + // result doesn't mention it, so the hash cannot move) + Exec("drop table if exists watch_fp_unrelated"); + Exec("create table watch_fp_unrelated(id int)"); + var fp5 = Fp(); + fp5.Should().Be(fp4, "an unrelated table is not part of the discovery result"); + Exec("drop table watch_fp_unrelated"); + + // a table USED as a return type → its shape is part of the result: altering it changes the endpoint + Exec("create table watch_fp_typed(id int)"); + Exec("create function watch_fp_typed_fn() returns setof watch_fp_typed language sql as 'select * from watch_fp_typed'"); + var fp6 = Fp(); + fp6.Should().NotBe(fp5, "a new routine (returning a table type) must change the fingerprint"); + Exec("alter table watch_fp_typed add column name text"); + var fp7 = Fp(); + fp7.Should().NotBe(fp6, "adding a column to a table used as a return type reshapes the endpoint"); + + // cleanup; drops → change + Exec("drop function watch_fp_typed_fn()"); + Exec("drop table watch_fp_typed"); + Exec("drop function watch_fp_test_fn(int)"); + var fp8 = Fp(); + fp8.Should().NotBe(fp7, "dropping functions must change the fingerprint"); + } +} diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 93cf8561..302763d8 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -281,7 +281,6 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "Keep": false, // skip Teardown (inspect state after a failed run) "DetailedReport": false, // detailed console report: passed ✓ lines, full failing SQL, notices for passing tests "AllowEmpty": false, // exit 0 instead of 4 when no tests are found - "Watch": false, // watch mode (CLI: --watch): re-run on file changes until Ctrl+C; teardown on exit "Coverage": null, // coverage summary: null (default) = on for full runs, quiet when narrowed; true/false = always/never "CoverageThreshold": null, // 0-100: always report + fail an otherwise-passing run (exit 2) below it "LoggerName": "NpgsqlRestTest", // the runner's log channel (leveled via Log:MinimalLevels) @@ -333,7 +332,7 @@ endpoint coverage: 1/2 (50%) Endpoint kinds the runner rejects (SSE, upload, login/logout, outbound proxy) are excluded from the ratio and counted separately, so the number is honest. **`CoverageThreshold`** (0–100) turns it into a CI gate — it always reports, regardless of the `Coverage` setting or run narrowing: an otherwise-passing run below the threshold exits `2` — set it to `100` and forgetting to write a test for a new endpoint fails the build, naming the endpoint. "Covered" means *invoked at least once by a test* — execution, not assertion depth (the same semantics as code coverage). -**Watch mode** (`--watch`, or `TestRunner:Watch`) keeps the process alive and re-runs on change — and because the endpoint middleware is built once at startup, re-runs are near-instant: +**Watch mode** (`--watch`, or `Watch:Enabled` in configuration) keeps the process alive and re-runs on change — and because the endpoint middleware is built once at startup, re-runs are near-instant: ``` npgsqlrest ./config.json ./test-config.json --test --watch @@ -396,14 +395,33 @@ What you get: ## 4. Watch mode: `--watch` -The `--watch` flag runs in one of **two modes**, depending on whether `--test` is present: +The `--watch` flag — shorthand for the `Watch:Enabled` configuration setting — runs in one of **two modes**, depending on whether `--test` is present: | Command | Mode | Watches | On change | |---|---|---|---| -| `npgsqlrest ... --test --watch` | **Test watch** | test files, included fixtures/profiles, and — when the SQL file source is enabled — the endpoint SQL files | changed test re-runs alone; endpoint change rebuilds endpoints in-process and re-runs everything | -| `npgsqlrest ... --watch` | **Server watch** | the SQL file source tree and the configuration files | the server restarts (~1s) | +| `npgsqlrest ... --test --watch` | **Test watch** | test files, included fixtures/profiles, the endpoint SQL files (when the SQL file source is enabled), and **the database catalog** | changed test re-runs alone; endpoint or database change rebuilds endpoints in-process and re-runs everything | +| `npgsqlrest ... --watch` | **Server watch** | the SQL file source tree, the configuration files, and **the database catalog** | the server restarts (~1s) | -Test watch works with or without a SQL file source (database-routine endpoints are testable too — they are just not watchable) and is described in the test runner section above. Server watch requires an **enabled SQL file source** — `--watch` without it (and without `--test`) has nothing to watch and exits with an error. +Test watch is described in the test runner section above. Server watch needs something to watch — an **enabled SQL file source**, **database polling** (on by default, below), or both; with neither, `--watch` without `--test` exits with an error. + +### Watching the routine source — database polling + +Routine-source endpoints (functions and procedures) have no files to watch — so watch mode polls the **database** instead, and it does it with perfect fidelity: the poll runs the **same routine discovery query the endpoint source uses**, with the same configured filters (schema/name/language includes and excludes), hashed server-side into a single value on a dedicated non-pooled connection (default every `2s`). If the hash changes, the discovered endpoints changed — *by definition*. That covers `create`/`create or replace`/`drop`/`alter` of functions and procedures (including `GRANT`/`REVOKE`), **`COMMENT ON` — i.e. annotation changes**, and changes to the **composite types and tables used as parameter or return types** (`alter table users add column` reshapes a `returns setof users` endpoint even though no function changed). Just as importantly, anything the discovery query does *not* read — an unrelated table, temp objects, data changes — can never cause a spurious restart. Any detected change triggers the same path a file change does: server watch restarts the server, test watch rebuilds endpoints in-process and re-runs the tests (`— change detected (database) —`); the test runner re-baselines after every rerun so self-inflicted changes never re-trigger. + +This makes a **routines-only** project fully watchable: run `npgsqlrest ./config.json --watch`, then `create or replace` a function in psql — the endpoint is live about two seconds later, annotations included. + +The whole feature lives in **one top-level configuration section** (the `--watch` flag is the shorthand for `Watch:Enabled`): + +```json +{ + "Watch": { + "Enabled": false, + "DatabasePollingInterval": "2s" + } +} +``` + +`DatabasePollingInterval` accepts `"2s"`, `"500ms"`, `"1m"`, plain seconds, or `"hh:mm:ss"`; `0` disables polling. Both settings apply to both watch flavors. ### Server watch @@ -421,6 +439,7 @@ Behavior: |---|---| | `.sql` change under the source tree | restart (files matching `SkipPattern` — test files — are ignored) | | configuration file change | restart with the new configuration | +| database routine change (detected by polling, above) | restart — `— database change detected — restarting —` | | broken SQL file | restart; the error is logged, that endpoint drops, everything else serves | | child crashes/exits on its own | supervisor prints `server exited (code N) — waiting for file changes` and revives on the next save (no crash-looping) | | Ctrl+C / SIGTERM (`docker stop`) | child stopped gracefully, both processes exit, port freed | @@ -460,4 +479,4 @@ Each named logger is controlled independently — e.g. mute the application logg ## Tests -Full test suite green (2393), including 63 unit tests for the test-file, HTTP-block, header-annotation, and include parsers plus the filter and tag matchers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`), and 29 for named SQL-file parameters — 22 rewriter unit tests (casts, `:=`, slices, strings, dollar-quotes, jsonb-path strings, case-insensitive repetition, cross-statement sharing, mixing detection, named type hints) plus 7 end-to-end endpoint tests (auto-naming through the camelCase converter, a repeated placeholder bound from one value, required-parameter matching, name-matched defaults, `type is` retype, claim mapping by placeholder name, mixed-style rejection, and a multi-command file sharing `:id` across statements as one API parameter). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown), watch mode (single-file rerun on a test change, full rerun on a fixture change, teardown on SIGINT/SIGTERM), path and tag filtering (including tags carried through a profile include), and the coverage report with a failing threshold gate. The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). +Full test suite green (2394), including 63 unit tests for the test-file, HTTP-block, header-annotation, and include parsers plus the filter and tag matchers (`NpgsqlRestTests/TestRunnerTests/ParserTests/`), and 29 for named SQL-file parameters — 22 rewriter unit tests (casts, `:=`, slices, strings, dollar-quotes, jsonb-path strings, case-insensitive repetition, cross-statement sharing, mixing detection, named type hints) plus 7 end-to-end endpoint tests (auto-naming through the camelCase converter, a repeated placeholder bound from one value, required-parameter matching, name-matched defaults, `type is` retype, claim mapping by placeholder name, mixed-style rejection, and a multi-command file sharing `:id` across statements as one API parameter), plus a database-fingerprint test proving the watch poller's hash tracks the routine discovery result exactly (function create/replace/comment/drop and used-type changes fire; temp objects and unrelated tables never do). Watch mode verified live end-to-end in both flavors: file edit/break/fix cycles, config-change restarts, crash recovery, orphan prevention under SIGKILL, graceful SIGTERM teardown, polling-watcher mode, and database-driven changes (a function created in psql serving ~2s later; `alter table` reshaping a `returns setof` endpoint; unrelated tables causing zero restarts). All three documentation examples verified end-to-end against live PostgreSQL — including example 21's template-clone workflow (template migrated once; the shared run database and **two parallel per-test isolated databases** cloned from it concurrently; deterministic sequence ids asserted independently in both clones; everything dropped on teardown), watch mode (single-file rerun on a test change, full rerun on a fixture change, teardown on SIGINT/SIGTERM), path and tag filtering (including tags carried through a profile include), and the coverage report with a failing threshold gate. The new configuration keys are covered by the configuration round-trip tests (the `--config` template output matches `appsettings.json`). From 76f4f56fbf344162b26c7387a366d64a97f77b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 20:09:55 +0200 Subject: [PATCH 11/14] =?UTF-8?q?feat(test-runner):=20ResponseTempTable.De?= =?UTF-8?q?bugTable=20=E2=80=94=20permanent=20response=20debug=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured responses live in per-connection temp tables that vanish with the test's rollback and connection close — impossible to examine in a query editor afterwards, and re-issuing the request from an .http file cannot reproduce a response that depended on the test's uncommitted transactional fixtures. New setting ResponseTempTable.DebugTable (default null = off): when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a PERMANENT table with that name, written on a separate autocommit pooled connection — immune to rollbacks and connection closes, append-only so parallel test files cannot collide. The temp-table semantics (assertions, naming, transactions) are completely unchanged. ONE table covers the whole run: each HTTP block adds one row with captured_at, test_file, block (that block's response-table name — Name, a MultiNamePattern ordinal, or the # @response name), method, path, status, body, content_type, headers, is_success. Recreated at the start of every run, so it always holds the LAST run. Enabling it prints a loud warning (debugging aid — do not enable in CI); mirror inserts are best-effort (a failure logs a warning, never fails the test). In the fresh-test-database workflow combine with Keep, or teardown drops the database and mirror. Note: the naive alternative ("Permanent": true on the response table) was rejected — parallel tests would collide on one permanent name, and CREATE TABLE is transactional, so the rollback would erase the table exactly when it was needed. Config synced (appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs). Full suite green (2394). Live-verified on example 19: all three block-naming schemes distinguishable in one query, including the response body of a rolled-back fixture; rerun leaves exactly the last run's rows. Changelog + docs updated. --- NpgsqlRestClient/Builder.cs | 1 + NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 12 +++ NpgsqlRestClient/Testing/TestRunner.cs | 90 ++++++++++++++++++- NpgsqlRestClient/Testing/TestRunnerOptions.cs | 13 +++ NpgsqlRestClient/appsettings.json | 2 + changelog/v3.19.0.md | 3 + 8 files changed, 121 insertions(+), 2 deletions(-) diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 8f1c8dbd..59702ecf 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3093,6 +3093,7 @@ public TestRunnerOptions BuildTestRunnerOptions() { opt.ResponseTempTable.Name = _config.GetConfigStr("Name", rt) ?? opt.ResponseTempTable.Name; opt.ResponseTempTable.MultiNamePattern = _config.GetConfigStr("MultiNamePattern", rt) ?? opt.ResponseTempTable.MultiNamePattern; + opt.ResponseTempTable.DebugTable = _config.GetConfigStr("DebugTable", rt) ?? opt.ResponseTempTable.DebugTable; var cols = rt.GetSection("Columns"); if (cols.Exists()) { diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 537fb66e..60eb888e 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1124,6 +1124,7 @@ private static JsonObject GetTestRunnerDefaults() { ["Name"] = "_response", ["MultiNamePattern"] = "_response_{n}", + ["DebugTable"] = null, ["Columns"] = new JsonObject { ["Status"] = "status", diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 4636a6f3..dfceec1b 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -266,6 +266,7 @@ public static partial class ConfigSchemaGenerator ["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.", ["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response `.", ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", + ["TestRunner:ResponseTempTable:DebugTable"] = "Debugging aid: when set (e.g. \"_responses_debug\"), every captured response is ALSO mirrored into a PERMANENT table with this name — written on a separate autocommit connection, so it survives the test's rollback and connection close and can be examined in a query editor after the run.\nRecreated at the start of every run (holds the LAST run). ONE table covers everything: each HTTP block adds one row, with the block column recording that block's response-table name (Name, MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body, content_type, headers, is_success, captured_at. Temp-table semantics unchanged. Do not enable in CI. Null (default) = off.", ["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.", ["TestRunner:Steps"] = "Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in Setup/Teardown, or from an individual test file's leading header comments:\n-- @setup StepName [StepName ...] (runs before that file); -- @teardown StepName [StepName ...] (runs after that file, always, best-effort); -- @connection Name (runs that file on a named ConnectionStrings entry).\nAnnotations are repeatable; names may be whitespace- or comma-separated and run in the order written.\nTest files can also reuse SQL scripts in place with psql-style includes: `\\i file` (cwd-relative) or `\\ir file` (relative to the including file) — executed on the test's connection, inside its transaction.", ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is a step NAME from \"Steps\", or an inline object: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 18119364..ff8d4ba8 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1526,6 +1526,18 @@ public static partial class ConfigSchemaGenerator "ResponseTempTable": { "Name": "_response", "MultiNamePattern": "_response_{n}", + // + // Debugging aid: when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a + // PERMANENT table with this name — written on a separate autocommit connection, so it survives the + // test's rollback and the connection close and can be examined in a query editor after the run. + // Recreated at the start of every run (always holds the LAST run). ONE table covers everything: each + // HTTP block adds one row, and the "block" column records that block's response-table name (Name, + // MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body, + // content_type, headers, is_success, captured_at. The temp-table semantics are unchanged. Do not + // enable in CI. In the fresh-test-database workflow combine with "Keep": true, or teardown drops the + // database (and the mirror with it). + // + "DebugTable": null, "Columns": { "Status": "status", "Body": "body", diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 94729fa9..2fc9d9f7 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -44,6 +44,10 @@ public sealed class TestRunner private readonly NpgsqlRestOptions _rest; private readonly TestRunnerOptions _opt; private readonly string _connString; + // The debug mirror writes on its own POOLED connections (unlike test connections, which are + // deliberately non-pooled) — autocommit, outside any test transaction. + private readonly string _debugConnString; + private bool _debugTableWarned; private readonly IReadOnlyDictionary _named; private readonly ILogger? _log; private readonly bool _logNotices; @@ -108,6 +112,7 @@ public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConn // Always non-pooled: a fresh physical session per test (no temp-table / GUC / prepared-statement carryover). // In test mode baseConnectionString is already the test connection (TestRunner.ConnectionName, when set). _connString = new NpgsqlConnectionStringBuilder(baseConnectionString) { Pooling = false }.ConnectionString; + _debugConnString = baseConnectionString; // Named ConnectionStrings entries for Setup/Teardown steps that target another connection (e.g. an // "Admin" maintenance connection that runs create/drop database). _named = namedConnections ?? new Dictionary(); @@ -239,6 +244,7 @@ private async Task ExecuteDiscoveredFilesAsync(RoutineEndpoint[] endpoints, { _failFast = false; // reset per run (watch mode reuses the runner across iterations) if (only is null) _invoked.Clear(); // coverage counts per full run (partial watch reruns don't report) + await EnsureDebugTableAsync(ct); // debug mirror holds the LAST run — recreated per run var files = only ?? DiscoverFiles(); if (only is null) @@ -710,7 +716,7 @@ private async Task RunFileAsync(string file, IReadOnlyDictionary ExecuteSqlStepAsync( } private async Task InvokeHttpStepAsync( - NpgsqlConnection conn, HttpStep step, string responseTable, IReadOnlyDictionary lookup, CancellationToken ct) + NpgsqlConnection conn, HttpStep step, string responseTable, IReadOnlyDictionary lookup, string testFile, CancellationToken ct) { var httpName = $"{step.Method} {step.Path}"; var pathOnly = StripQuery(step.Path); @@ -894,6 +900,10 @@ private async Task InvokeHttpStepAsync( step.Method, step.Path, response.StatusCode, responseTable); await WriteResponseAsync(conn, responseTable, response, ct); + if (string.IsNullOrWhiteSpace(_opt.ResponseTempTable.DebugTable) is false) + { + await MirrorResponseAsync(testFile, responseTable, step, response, ct); + } // An HTTP block is an act step: it captures the response into the temp table and produces no test // of its own — the assertions are the boolean SELECTs that follow it. It only surfaces as a result @@ -942,6 +952,82 @@ private async Task WriteResponseAsync(NpgsqlConnection conn, string table, Routi await ins.ExecuteNonQueryAsync(ct); } + /// + /// Debug mirror (ResponseTempTable.DebugTable): recreate the PERMANENT mirror table at the start of a + /// run and print the do-not-use-in-CI warning once. Runs on its own autocommit connection, so mirrored + /// rows survive test rollbacks and connection closes — that is the whole point. + /// + private async Task EnsureDebugTableAsync(CancellationToken ct) + { + var table = _opt.ResponseTempTable.DebugTable; + if (string.IsNullOrWhiteSpace(table)) + { + return; + } + if (!ValidateIdentifier(table)) + { + throw new InvalidOperationException($"invalid ResponseTempTable.DebugTable name '{table}'"); + } + if (_debugTableWarned is false) + { + _debugTableWarned = true; + _out.Line($"WARNING: ResponseTempTable.DebugTable is enabled — every captured response is written to the PERMANENT table \"{table}\" (debugging aid; do not enable in CI)", ConsoleColor.Yellow); + _log?.LogWarning("ResponseTempTable.DebugTable enabled: mirroring responses into permanent table {Table}", table); + } + await using var conn = new NpgsqlConnection(_debugConnString); + await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + drop table if exists "{table}"; + create table "{table}" ( + captured_at timestamptz not null default now(), + test_file text not null, + block text not null, + method text not null, + path text not null, + status int, + body text, + content_type text, + headers jsonb, + is_success boolean + ) + """; + // The debug connection is plain Npgsql (SQL rewriting enabled is irrelevant here — no parameters), + // but the client disables rewriting globally, so run the two statements separately. + foreach (var statement in cmd.CommandText.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + await using var one = new NpgsqlCommand(statement, conn); + await one.ExecuteNonQueryAsync(ct); + } + } + + /// Best-effort mirror insert — a failure warns but never fails the test. + private async Task MirrorResponseAsync(string testFile, string block, HttpStep step, RoutineInvokeResult response, CancellationToken ct) + { + try + { + await using var conn = new NpgsqlConnection(_debugConnString); + await conn.OpenAsync(ct); + await using var ins = new NpgsqlCommand( + $"insert into \"{_opt.ResponseTempTable.DebugTable}\" (test_file, block, method, path, status, body, content_type, headers, is_success) values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)", + conn); + ins.Parameters.AddWithValue(testFile); + ins.Parameters.AddWithValue(block); + ins.Parameters.AddWithValue(step.Method.ToString()); + ins.Parameters.AddWithValue(step.Path); + ins.Parameters.AddWithValue(response.StatusCode); + ins.Parameters.AddWithValue((object?)response.Body ?? DBNull.Value); + ins.Parameters.AddWithValue((object?)response.ContentType ?? DBNull.Value); + ins.Parameters.AddWithValue((object?)response.Headers ?? DBNull.Value); + ins.Parameters.AddWithValue(response.IsSuccess); + await ins.ExecuteNonQueryAsync(ct); + } + catch (Exception ex) + { + _log?.LogWarning("debug table write failed for {File}: {Message}", testFile, ex.Message); + } + } + // The temp-table name for an HTTP block: explicit `# @response` wins; otherwise a file with a single // HTTP block uses Name (`_response`), and a file with 2+ blocks uses MultiNamePattern with {n} = the // 1-based block ordinal (`_response_1`, `_response_2`, …). diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index e1ff374a..5417983e 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -112,6 +112,19 @@ public class ResponseTempTableOptions /// Temp table name pattern when a file has 2+ HTTP blocks. {n} = the 1-based block ordinal. public string MultiNamePattern { get; set; } = "_response_{n}"; + /// + /// Debugging aid: when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a + /// PERMANENT table with this name — written on a separate autocommit connection, so it survives the + /// test's rollback and the connection close, and can be examined in a query editor after the run. + /// Recreated at the start of every run (always holds the LAST run). ONE table covers all blocks and + /// files: each HTTP block adds one row, with the block column recording that block's response-table + /// name (, a ordinal, or the `# @response` name) + /// alongside test_file/method/path and the response columns. The temp-table semantics are unchanged. + /// Null (default) = off. Do not enable in CI. In the fresh-test-database workflow combine with Keep, + /// or teardown drops the database (and the mirror with it). + /// + public string? DebugTable { get; set; } = null; + public ResponseColumnOptions Columns { get; set; } = new(); } diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 450ada4e..b46f78e5 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1517,6 +1517,8 @@ "ResponseTempTable": { "Name": "_response", "MultiNamePattern": "_response_{n}", + + "DebugTable": null, "Columns": { "Status": "status", "Body": "body", diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 302763d8..f2a65443 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -123,6 +123,8 @@ select (select body::jsonb ->> 'email' from _response) = 'grace@example.com', 'email is normalized to lowercase'; ``` +**Debugging captured responses** — temp tables vanish with the test's rollback and connection, so you cannot inspect them afterwards (and re-issuing the request from an `.http` file cannot reproduce a response that depended on the test's uncommitted fixtures). Set **`ResponseTempTable.DebugTable`** (e.g. `"_responses_debug"`; default `null` = off) and every captured response is **also mirrored into a permanent table** — written on a separate autocommit connection, immune to rollbacks, recreated at the start of every run so it always holds the last run. One table covers everything: each HTTP block adds one row, with `test_file`, `block` (that block's response-table name — `_response`, a `_response_{n}` ordinal, or the `# @response` name), `method`, `path`, `status`, `body`, `content_type`, `headers`, `is_success`, and `captured_at` — so after a run you can open a query editor and dig into any response with jsonb operators. The temp-table semantics are unchanged; enabling it prints a loud warning (debugging aid — do not enable in CI). In the fresh-test-database workflow combine it with `Keep: true`, or teardown drops the database and the mirror with it. + ### Reusing scripts: `\i` and `\ir` includes Shared SQL — fixture inserts, utility scripts — can be spliced into any test with psql's include syntax on its own line: @@ -287,6 +289,7 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "ResponseTempTable": { "Name": "_response", // table name when a file has ONE HTTP block "MultiNamePattern": "_response_{n}",// name pattern for 2+ blocks; {n} = 1-based block position + "DebugTable": null, // debugging aid: ALSO mirror every response into this PERMANENT table (survives rollback; last run; not for CI) "Columns": { // response → column mapping; null/empty omits the column "Status": "status", "Body": "body", "ContentType": "content_type", "Headers": "headers", "IsSuccess": "is_success" From 0ef7a2ff6117d0701c2cd874ba056154f7e07a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 20:18:40 +0200 Subject: [PATCH 12/14] fix(config): sync ConfigTemplate with the trimmed DebugTable comment in appsettings.json The DebugTable comment block was removed from appsettings.json (leaving a stray whitespace-only line) but ConfigTemplate.cs still carried it, so the Config_OutputMatchesAppsettingsJson byte-match test failed on CI. appsettings.json is the source of truth: the stray line is removed and the template now matches it exactly. The setting stays documented in the JSON schema description, the changelog, and the docs site. --- NpgsqlRestClient/ConfigTemplate.cs | 11 ----------- NpgsqlRestClient/appsettings.json | 1 - 2 files changed, 12 deletions(-) diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index ff8d4ba8..cd69451e 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1526,17 +1526,6 @@ public static partial class ConfigSchemaGenerator "ResponseTempTable": { "Name": "_response", "MultiNamePattern": "_response_{n}", - // - // Debugging aid: when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a - // PERMANENT table with this name — written on a separate autocommit connection, so it survives the - // test's rollback and the connection close and can be examined in a query editor after the run. - // Recreated at the start of every run (always holds the LAST run). ONE table covers everything: each - // HTTP block adds one row, and the "block" column records that block's response-table name (Name, - // MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body, - // content_type, headers, is_success, captured_at. The temp-table semantics are unchanged. Do not - // enable in CI. In the fresh-test-database workflow combine with "Keep": true, or teardown drops the - // database (and the mirror with it). - // "DebugTable": null, "Columns": { "Status": "status", diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index b46f78e5..e7059749 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1517,7 +1517,6 @@ "ResponseTempTable": { "Name": "_response", "MultiNamePattern": "_response_{n}", - "DebugTable": null, "Columns": { "Status": "status", From 536b122c629b5789491ad47ac1b2fce957f44ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 21:01:31 +0200 Subject: [PATCH 13/14] feat(test-runner): Steps.Enabled + shipped disabled example steps; "ms" interval support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Setup/Teardown step now has an "Enabled" flag (default true). A disabled step is simply IGNORED wherever it is referenced — Setup/Teardown arrays or per-file -- @setup / -- @teardown annotations — skipped with a debug log line, never an error. Referencing an UNKNOWN name remains a loud configuration error; Enabled=false is the one sanctioned skip. The default configuration ships six DISABLED example steps in TestRunner:Steps showing every step property (Sql, SqlFile, Command, WorkingDirectory, ConnectionName, Enabled) across the typical scenarios: CreateTestDatabase / DropTestDatabase ({rnd5}-named database on an "Admin" connection), ApplySchema (SqlFile), RunMigrationTool (Command + WorkingDirectory), StartDockerPostgres / StopDockerPostgres. Instead of typing a step from scratch, copy one, adjust names/paths/connections, and flip Enabled to true. (Defaults are a copy-from reference visible in appsettings.json and --config output — not a runtime layer merged into explicit config files.) Also from the config-comment review: - ParseTestTimeout gained "ms" support ("500ms") — the Watch comments and changelog already promised it but the parser silently fell back to the default (verified: --watch:databasepollinginterval=500ms -> poll 0.5s). - ResponseTempTable section comment carries a compact one-line DebugTable description; FilePattern comment names both test layouts. Config synced x4 + schema (byte-match green). Full suite green (2394). Live-verified: disabled-referenced step skipped with debug line + run green; flipped on via CLI override -> step executes; custom user step names produce no validation warnings (example 20 clean). Changelog + docs updated. --- NpgsqlRestClient/Builder.cs | 5 +++ NpgsqlRestClient/ConfigDefaults.cs | 37 ++++++++++++++++++- NpgsqlRestClient/ConfigSchemaGenerator.cs | 2 +- NpgsqlRestClient/ConfigTemplate.cs | 23 ++++++++++-- NpgsqlRestClient/Testing/TestRunner.cs | 7 ++++ NpgsqlRestClient/Testing/TestRunnerOptions.cs | 7 ++++ NpgsqlRestClient/appsettings.json | 23 ++++++++++-- changelog/v3.19.0.md | 20 +++++++++- 8 files changed, 113 insertions(+), 11 deletions(-) diff --git a/NpgsqlRestClient/Builder.cs b/NpgsqlRestClient/Builder.cs index 59702ecf..be187075 100644 --- a/NpgsqlRestClient/Builder.cs +++ b/NpgsqlRestClient/Builder.cs @@ -3140,6 +3140,7 @@ static List SplitNames(string? value) => { var step = new TestSetupStep { + Enabled = _config.GetConfigBool("Enabled", child, true), Sql = _config.GetConfigStr("Sql", child), SqlFile = _config.GetConfigStr("SqlFile", child), Command = _config.GetConfigStr("Command", child), @@ -3186,6 +3187,10 @@ internal static TimeSpan ParseTestTimeout(string? raw, TimeSpan def) if (string.IsNullOrWhiteSpace(raw)) return def; raw = raw.Trim(); if (int.TryParse(raw, out var secs)) return TimeSpan.FromSeconds(secs); + if (raw.Length > 2 && raw.EndsWith("ms", StringComparison.OrdinalIgnoreCase) && int.TryParse(raw[..^2], out var ms)) + { + return TimeSpan.FromMilliseconds(ms); + } if (raw.Length > 1 && int.TryParse(raw[..^1], out var n)) { switch (char.ToLowerInvariant(raw[^1])) diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 60eb888e..25f09878 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -1134,7 +1134,42 @@ private static JsonObject GetTestRunnerDefaults() ["IsSuccess"] = "is_success" } }, - ["Steps"] = new JsonObject(), + ["Steps"] = new JsonObject + { + ["CreateTestDatabase"] = new JsonObject + { + ["Enabled"] = false, + ["ConnectionName"] = "Admin", + ["Sql"] = "create database app_test_{rnd5}", + }, + ["DropTestDatabase"] = new JsonObject + { + ["Enabled"] = false, + ["ConnectionName"] = "Admin", + ["Sql"] = "drop database if exists app_test_{rnd5} with (force)", + }, + ["ApplySchema"] = new JsonObject + { + ["Enabled"] = false, + ["SqlFile"] = "./migrations/schema.sql", + }, + ["RunMigrationTool"] = new JsonObject + { + ["Enabled"] = false, + ["Command"] = "echo replace with your migration tool command", + ["WorkingDirectory"] = ".", + }, + ["StartDockerPostgres"] = new JsonObject + { + ["Enabled"] = false, + ["Command"] = "docker run -d --name npgsqlrest-test-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres", + }, + ["StopDockerPostgres"] = new JsonObject + { + ["Enabled"] = false, + ["Command"] = "docker rm -f npgsqlrest-test-pg", + }, + }, ["Setup"] = new JsonArray(), ["Teardown"] = new JsonArray() }; diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index dfceec1b..0a262390 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -268,7 +268,7 @@ public static partial class ConfigSchemaGenerator ["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …", ["TestRunner:ResponseTempTable:DebugTable"] = "Debugging aid: when set (e.g. \"_responses_debug\"), every captured response is ALSO mirrored into a PERMANENT table with this name — written on a separate autocommit connection, so it survives the test's rollback and connection close and can be examined in a query editor after the run.\nRecreated at the start of every run (holds the LAST run). ONE table covers everything: each HTTP block adds one row, with the block column recording that block's response-table name (Name, MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body, content_type, headers, is_success, captured_at. Temp-table semantics unchanged. Do not enable in CI. Null (default) = off.", ["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.", - ["TestRunner:Steps"] = "Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in Setup/Teardown, or from an individual test file's leading header comments:\n-- @setup StepName [StepName ...] (runs before that file); -- @teardown StepName [StepName ...] (runs after that file, always, best-effort); -- @connection Name (runs that file on a named ConnectionStrings entry).\nAnnotations are repeatable; names may be whitespace- or comma-separated and run in the order written.\nTest files can also reuse SQL scripts in place with psql-style includes: `\\i file` (cwd-relative) or `\\ir file` (relative to the including file) — executed on the test's connection, inside its transaction.", + ["TestRunner:Steps"] = "Named, reusable steps (name -> step; same shape as Setup/Teardown entries): \"Sql\", \"SqlFile\", \"Command\", \"WorkingDirectory\", \"ConnectionName\", \"Enabled\". Reference them by name in Setup/Teardown or from a test file's leading header comments (-- @setup Name / -- @teardown Name).\nA step with Enabled=false is simply IGNORED wherever referenced — the default configuration ships disabled example steps (create/drop test database, apply schema, run a migration tool, start/stop a Docker PostgreSQL) to flip on instead of typing.", ["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is a step NAME from \"Steps\", or an inline object: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).", ["TestRunner:Teardown"] = "Run-once teardown, ALWAYS (best-effort), in the EXACT order written. Same entries as Setup (step names or inline objects, e.g. a `drop database` on an admin connection). The `Keep` option skips it.", ["CommandRetryOptions"] = "Command retry strategies and options for client and middleware commands.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index cd69451e..4075a6c7 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1443,8 +1443,9 @@ public static partial class ConfigSchemaGenerator // "TestRunner": { // - // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. - // Co-located convention: app.sql (endpoint) next to app.test.sql (test). + // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. Two layouts: + // co-located (app.sql next to app.test.sql — SqlFileSource SkipPattern keeps tests out of the endpoints) + // or a separate tests tree (e.g. "./tests/**/*.test.sql"). // "FilePattern": "", // @@ -1521,7 +1522,9 @@ public static partial class ConfigSchemaGenerator // (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. // A file with ONE HTTP block uses "Name"; a file with 2+ blocks uses "MultiNamePattern" where {n} is // the 1-based block ordinal (_response_1, _response_2, ...). Per-block override: `# @response `. - // A null/empty column name omits that column. + // A null/empty column name omits that column. "DebugTable" (e.g. "_responses_debug"): ALSO mirror every + // response into a PERMANENT table for post-run inspection — survives rollbacks, holds the last run, one + // row per HTTP block with test_file/block/method/path metadata (debugging aid; do not enable in CI). // "ResponseTempTable": { "Name": "_response", @@ -1545,7 +1548,19 @@ public static partial class ConfigSchemaGenerator // Test files can also reuse SQL scripts in place with psql-style includes: `\i file` (cwd-relative) or // `\ir file` (relative to the including file) — executed on the test's connection, inside its transaction. // - "Steps": {}, + // The entries below are disabled EXAMPLES showing every step property ("Sql", "SqlFile", "Command", + // "WorkingDirectory", "ConnectionName") across the typical scenarios — flip "Enabled" to true (and adjust + // names, paths, and connections) instead of typing them from scratch. A step with "Enabled": false is + // simply IGNORED wherever it is referenced. + // + "Steps": { + "CreateTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "create database app_test_{rnd5}" }, + "DropTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "drop database if exists app_test_{rnd5} with (force)" }, + "ApplySchema": { "Enabled": false, "SqlFile": "./migrations/schema.sql" }, + "RunMigrationTool": { "Enabled": false, "Command": "echo replace with your migration tool command", "WorkingDirectory": "." }, + "StartDockerPostgres": { "Enabled": false, "Command": "docker run -d --name npgsqlrest-test-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres" }, + "StopDockerPostgres": { "Enabled": false, "Command": "docker rm -f npgsqlrest-test-pg" } + }, // // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is a step // NAME from "Steps" above, or an inline step object: diff --git a/NpgsqlRestClient/Testing/TestRunner.cs b/NpgsqlRestClient/Testing/TestRunner.cs index 2fc9d9f7..cf58e70e 100644 --- a/NpgsqlRestClient/Testing/TestRunner.cs +++ b/NpgsqlRestClient/Testing/TestRunner.cs @@ -193,6 +193,13 @@ public async Task SetupAsync(CancellationToken ct = default) // `phase` ("setup" | "teardown") is only used to label the step's log lines. private async Task RunStepAsync(TestSetupStep step, string phase, CancellationToken ct) { + if (step.Enabled is false) + { + // Disabled steps are ignored wherever referenced — the default config ships example steps + // ("Enabled": false) that users flip on instead of typing them from scratch. + _log?.LogDebug("{Phase} step {Step}: skipped (Enabled is false)", phase, step.Name ?? step.SqlFile ?? step.Command ?? "(inline)"); + return; + } if (step.IsCommand) { await RunCommandAsync(step, phase, ct); diff --git a/NpgsqlRestClient/Testing/TestRunnerOptions.cs b/NpgsqlRestClient/Testing/TestRunnerOptions.cs index 5417983e..eefbfc99 100644 --- a/NpgsqlRestClient/Testing/TestRunnerOptions.cs +++ b/NpgsqlRestClient/Testing/TestRunnerOptions.cs @@ -143,6 +143,13 @@ public class TestSetupStep /// Registry name when the step is defined under Steps; null for inline steps. Used in logs. public string? Name { get; set; } + /// + /// A disabled step is simply IGNORED wherever it is referenced (Setup/Teardown, per-file + /// -- @setup/-- @teardown annotations) — skipped with a debug log line, never an error. + /// Lets the default configuration ship ready-made example steps that users flip on instead of typing. + /// + public bool Enabled { get; set; } = true; + /// Inline SQL executed as a single batch. public string? Sql { get; set; } diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index e7059749..4d88af0a 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1434,8 +1434,9 @@ // "TestRunner": { // - // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. - // Co-located convention: app.sql (endpoint) next to app.test.sql (test). + // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. Two layouts: + // co-located (app.sql next to app.test.sql — SqlFileSource SkipPattern keeps tests out of the endpoints) + // or a separate tests tree (e.g. "./tests/**/*.test.sql"). // "FilePattern": "", // @@ -1512,7 +1513,9 @@ // (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. // A file with ONE HTTP block uses "Name"; a file with 2+ blocks uses "MultiNamePattern" where {n} is // the 1-based block ordinal (_response_1, _response_2, ...). Per-block override: `# @response `. - // A null/empty column name omits that column. + // A null/empty column name omits that column. "DebugTable" (e.g. "_responses_debug"): ALSO mirror every + // response into a PERMANENT table for post-run inspection — survives rollbacks, holds the last run, one + // row per HTTP block with test_file/block/method/path metadata (debugging aid; do not enable in CI). // "ResponseTempTable": { "Name": "_response", @@ -1536,7 +1539,19 @@ // Test files can also reuse SQL scripts in place with psql-style includes: `\i file` (cwd-relative) or // `\ir file` (relative to the including file) — executed on the test's connection, inside its transaction. // - "Steps": {}, + // The entries below are disabled EXAMPLES showing every step property ("Sql", "SqlFile", "Command", + // "WorkingDirectory", "ConnectionName") across the typical scenarios — flip "Enabled" to true (and adjust + // names, paths, and connections) instead of typing them from scratch. A step with "Enabled": false is + // simply IGNORED wherever it is referenced. + // + "Steps": { + "CreateTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "create database app_test_{rnd5}" }, + "DropTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "drop database if exists app_test_{rnd5} with (force)" }, + "ApplySchema": { "Enabled": false, "SqlFile": "./migrations/schema.sql" }, + "RunMigrationTool": { "Enabled": false, "Command": "echo replace with your migration tool command", "WorkingDirectory": "." }, + "StartDockerPostgres": { "Enabled": false, "Command": "docker run -d --name npgsqlrest-test-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres" }, + "StopDockerPostgres": { "Enabled": false, "Command": "docker rm -f npgsqlrest-test-pg" } + }, // // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is a step // NAME from "Steps" above, or an inline step object: diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index f2a65443..2f64d0e9 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -164,6 +164,23 @@ Run-once steps around the whole test session. **`Setup` runs before endpoint dis Steps can be defined once in the **`Steps` registry** (name → step, like `ConnectionStrings` or `CacheOptions.Profiles`) and referenced by name; `Setup`/`Teardown` arrays accept names and inline objects mixed. Referencing an unknown name is a configuration error (exit 3). +Every step also has an **`Enabled`** flag (default `true`): a disabled step is simply **ignored** wherever it is referenced — skipped with a debug log line, never an error. The default configuration ships **disabled example steps** covering the typical scenarios (create/drop a `{rnd}`-named test database on an admin connection, apply a schema file, run a migration tool, start/stop a Docker PostgreSQL) — they show every step property in place, so instead of typing a step from scratch you copy one, adjust the names, and flip `Enabled` to `true`: + +```json +{ + "TestRunner": { + "Steps": { + "CreateTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "create database app_test_{rnd5}" }, + "DropTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "drop database if exists app_test_{rnd5} with (force)" }, + "ApplySchema": { "Enabled": false, "SqlFile": "./migrations/schema.sql" }, + "RunMigrationTool": { "Enabled": false, "Command": "echo replace with your migration tool command", "WorkingDirectory": "." }, + "StartDockerPostgres": { "Enabled": false, "Command": "docker run -d --name npgsqlrest-test-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres" }, + "StopDockerPostgres": { "Enabled": false, "Command": "docker rm -f npgsqlrest-test-pg" } + } + } +} +``` + ```jsonc { "TestRunner": { @@ -295,7 +312,8 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v "Headers": "headers", "IsSuccess": "is_success" } }, - "Steps": {}, // named, reusable steps (name → step) for Setup/Teardown and -- @setup/-- @teardown + "Steps": { }, // named, reusable steps (name → step) for Setup/Teardown and -- @setup/-- @teardown; + // each has "Enabled" (false = ignored wherever referenced); ships disabled examples "Setup": [], // run-once, BEFORE endpoint discovery, in written order (step names or inline objects) "Teardown": [] // run-once, ALWAYS, in written order (Keep skips; same entries as Setup) } From 7573f8f9cb6b34e31530e8079c3e7c2e0c9bb40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Fri, 3 Jul 2026 21:13:58 +0200 Subject: [PATCH 14/14] bump 3.19.0 and update skill files --- .claude/skills/npgsqlrest/SKILL.md | 56 ++++- .../npgsqlrest/annotations-reference.md | 7 +- .../npgsqlrest/configuration-reference.jsonc | 202 +++++++++++++++++- .../CommentParsers/AnnotationReference.cs | 4 +- README.md | 87 ++++++-- changelog/v3.19.0.md | 19 +- docker/Dockerfile.jit | 3 + npm/package.json | 2 +- version.txt | 2 +- 9 files changed, 336 insertions(+), 46 deletions(-) diff --git a/.claude/skills/npgsqlrest/SKILL.md b/.claude/skills/npgsqlrest/SKILL.md index 2cb12cd5..4b7c9da7 100644 --- a/.claude/skills/npgsqlrest/SKILL.md +++ b/.claude/skills/npgsqlrest/SKILL.md @@ -1,6 +1,6 @@ --- name: npgsqlrest -description: Build and modify REST APIs with NpgsqlRest — exposing PostgreSQL as HTTP endpoints from two sources (database functions/procedures/tables/views, and plain .sql files), driven by SQL comment annotations (no C# needed). Use when working in an NpgsqlRest project: writing or changing endpoint SQL (functions or .sql files), comment annotations (HTTP routing, authorize, cached, proxy, HTTP Custom Types, SSE, MCP, upload), appsettings.json config, or running/troubleshooting the `npgsqlrest` client. +description: Build and modify REST APIs with NpgsqlRest — exposing PostgreSQL as HTTP endpoints from two sources (database functions/procedures/tables/views, and plain .sql files), driven by SQL comment annotations (no C# needed). Use when working in an NpgsqlRest project: writing or changing endpoint SQL (functions or .sql files), comment annotations (HTTP routing, authorize, cached, proxy, HTTP Custom Types, SSE, MCP, upload), appsettings.json config, testing endpoints with SQL test files (`--test`), running in watch mode (`--watch`), or running/troubleshooting the `npgsqlrest` client. --- # Working with NpgsqlRest @@ -67,20 +67,20 @@ Annotations live in **leading line comments** (`-- ...` or `/* ... */`) in a `.s { "NpgsqlRest": { "SqlFileSource": { "Enabled": true, "FilePattern": "sql/**/*.sql" } } } ``` +Files matching `SkipPattern` (default `"*.test.sql"`) are excluded from endpoint discovery — they are test files for the [test runner](#testing-sql-test-files---test), which makes the co-located layout (`app.sql` next to `app.test.sql`) safe. + ```sql -- sql/get-reports.sql -- HTTP GET --- @param $1 from_date --- @param $2 to_date -- @authorize select id, title, created_at from reports -where created_at between $1 and $2; +where created_at between :from_date and :to_date; ``` - **Filename → path**: `get-reports.sql` → `/api/get-reports`. - **Startup Describe = static type checking.** Each statement is parsed/described against the live DB (no execution); SQL errors fail startup (`ErrorMode: Exit`; set `Skip` to log-and-continue). This catches `column does not exist` etc. before serving. -- **Parameters are positional** (`$1, $2`). `@param $N name [type] [default ...]` names/retypes/defaults them (positional params have no native DEFAULT). `@define_param name [type]` creates a *virtual* param (for placeholders/claims; not bound to SQL). +- **Parameters: named (`:name`) or positional (`$1, $2`)** — one style per file (mixing is a startup error). **Prefer named**: the placeholder IS the parameter name (camelCased for the API: `:from_date` → `fromDate`), a repeated name is ONE parameter (also across statements), and claim mappings (`@user_parameters` + `:_user_id`) hook up with zero annotations. `@param` then only handles type/default: `@param from_date default null`, `@param :from_date date` (Describe type hint), `@param from_date type is date` (retype without rename). Positional: `@param $N name [type] [default ...]` names/retypes/defaults. `@define_param name [type]` creates a *virtual* param (placeholders/claims; not bound to SQL). Tokenizer caveats for `:name`: `::casts`, `:=`, and `a[1:3]` never match; a variable slice bound needs a space (`a[1 : n]`). - **Verb inference** when no `HTTP` tag: `SELECT`→GET, `INSERT`→PUT, `UPDATE`→POST, `DELETE`→DELETE, `DO`→POST (most destructive wins). Explicit `-- HTTP POST` overrides. - **Multi-command files** (statements split on `;`) run in **one batch / round-trip** and return a JSON object keyed per statement. Positional annotations apply to the *next* statement (or inline after `;`): - `@result name` — name the result key (default `result1`, `result2`, …) @@ -120,6 +120,9 @@ Same annotations apply to functions and `.sql` files. Confirm exact syntax/alias **Other** - `@rate_limiter_policy name`. `@command_timeout 30s`. `@connection Name` (multi-connection). `@buffer_rows N`. `@validate _email using required, email`. `@error_code_policy 23505 -> 409`. `@upload [for csv|excel|file_system|large_object]`. +**Test files only** (`*.test.sql`, run by `--test` — not endpoint annotations) +- Header: `-- @setup Step ...`, `-- @teardown Step ...`, `-- @connection Name`, `-- @tag a, b`. Inside HTTP blocks: `# @claim name=value`, `# @response name`. Includes: `\i file` / `\ir file`. + ## HTTP Custom Types (outbound HTTP from SQL) A **composite type** whose comment defines an outbound HTTP request. When a routine (or `.sql` param) is of that type, NpgsqlRest performs the call **before** running the endpoint and fills the composite fields. @@ -215,10 +218,48 @@ npgsqlrest # appsettings.json in cwd npgsqlrest ./config/appsettings.json ./config/appsettings.development.json npgsqlrest --connectionstrings:default="Host=localhost;Database=db;Username=postgres;Password=postgres" npgsqlrest --log:minimallevels:npgsqlrest=debug # see every annotation parsed +npgsqlrest ./config.json --test # run SQL test files, then exit (0 pass / 1 fail / 2 error / 3 config / 4 none) +npgsqlrest ./config.json --watch # dev server: restart on SQL file / config / database routine changes +npgsqlrest ./config.json --test --watch # dev test loop: re-run tests on changes ``` Install via the GitHub release binary, `npm install -g npgsqlrest`, or the `vbilopav/npgsqlrest` Docker image. +## Testing: SQL test files (`--test`) + +Tests are plain `.sql` files (glob: `TestRunner.FilePattern`, e.g. `./tests/**/*.test.sql` or co-located next to endpoints). Each file runs on its **own non-pooled connection**, in parallel; endpoints are invoked **in-process** (full pipeline: routing, auth, params, serialization) **on the test's own connection/transaction** — so a test can `begin`, insert fixtures, call the endpoint (it sees the uncommitted rows), assert, and `rollback`. + +```sql +begin; +insert into users (email) values ('x@example.com'); + +/* +GET /api/get-users +# @claim user_id=1 +*/ +select status = 200, 'authenticated caller gets 200' from _response; +select body::jsonb @> '[{"email": "x@example.com"}]', 'fixture listed' from _response; + +rollback; +``` + +Essentials: +- **Assertions**: a SELECT whose first column is `boolean` (2nd column = assertion name), or a `do $$ ... assert ... $$` block. Everything else is arrange/act. +- **HTTP blocks**: block comment, first line `METHOD /full/path` (incl. `/api` prefix). Directives: `# @claim name=value` (repeatable; any claim = authenticated principal, none = anonymous), `# @response name` (custom capture table). Body after a blank line. Response lands in temp table `_response` (`_response_{n}` for 2+ blocks): `status int, body text, content_type text, headers jsonb, is_success boolean`. +- **Per-file header annotations** (leading `--` comments, TEST files only): `-- @setup StepName ...`, `-- @teardown StepName ...`, `-- @connection Name` (perfect isolation: run the file on its own database), `-- @tag smoke, slow`. Reuse scripts with psql-style `\i file` / `\ir file` includes (paste semantics). +- **Setup/Teardown/Steps** (`TestRunner` config): run-once steps in written order — `{ "Sql": ... }` / `{ "SqlFile": ... }` / `{ "Command": ... }`, each with optional `ConnectionName` and `Enabled` (false = ignored wherever referenced; the default config ships disabled examples to flip on). `Setup` runs BEFORE endpoint discovery → it can `create database app_test_{rnd5}` on an admin connection; `TestRunner.ConnectionName` points the whole run at it; teardown drops it (guaranteed — runs even on Ctrl+C/SIGTERM). `{rnd1}`..`{rnd10}` are per-run-stable random tokens (`{rndN_1}`.. for independent instances). +- **Selection**: `--testrunner:filter=login` (path substring/glob), `--testrunner:tag=smoke --testrunner:excludetag=slow`. +- **Endpoint coverage** reports after full runs (on by default; `CoverageThreshold: 100` fails the build naming untested endpoints). `JUnitOutput` for CI. +- **Debugging**: `DetailedReport: true` (richer report); `ResponseTempTable.DebugTable: "_responses_debug"` mirrors every response into a permanent table (survives rollback; query it after the run; not for CI); the `NpgsqlRestTest` log channel at `Verbose` shows every statement and HTTP call. + +## Watch mode (`--watch`) + +One flag (`--watch`, or config `Watch: { "Enabled": true }`), two flavors chosen by `--test`: +- **Server watch** (`--watch`): a supervisor restarts the server (~1s) on `.sql` source changes (SkipPattern-filtered), **configuration file** changes, and **database routine changes** — polling runs the routine discovery query hashed server-side (`Watch:DatabasePollingInterval`, default `2s`; detects create/replace/drop/comment on functions, grants, and type/table shape changes; never fires on unrelated objects). Code generation (TS client, HTTP files) re-runs every cycle. A broken SQL file logs its error and drops only its endpoint (`ErrorMode` forced to `Skip` while watching). +- **Test watch** (`--test --watch`): a changed test re-runs alone; a changed endpoint file or database routine rebuilds endpoints in-process (endpoint delta reported) and re-runs everything; teardown runs once, on exit. + +For Docker Desktop bind mounts set `DOTNET_USE_POLLING_FILE_WATCHER=1` (file events only; database polling is unaffected). + ## Project conventions worth copying (from real projects) - **One schema for the API** (`IncludeSchemas: ["myapi"]`); internal helpers in another schema. @@ -234,5 +275,8 @@ Install via the GitHub release binary, `npm install -g npgsqlrest`, or the `vbil - **HTTP-type directives** are safest **before the request line**. - **Passthrough proxy doesn't run the function** — declare proxy-response params (transform mode) if you need the body executed. - **`HttpClientOptions.Enabled` / `ProxyOptions.Enabled` / `SqlFileSource.Enabled`** must be true for those features. `@cache` on a non-GET HTTP type is ignored with a warning. -- **SQL files:** annotations are in `--`/`/* */` comments; params are positional `$N` (name via `@param`); a startup Describe error fails boot unless `ErrorMode: Skip`; use `@returns` for statements over not-yet-existing objects (temp tables); `DO` blocks can't take `$N` or return values. +- **SQL files:** annotations are in `--`/`/* */` comments; params are named `:name` (preferred — auto-named) or positional `$N` (name via `@param`) — **never mixed in one file** (startup error); a startup Describe error fails boot unless `ErrorMode: Skip`; use `@returns` for statements over not-yet-existing objects (temp tables); `DO` blocks can't take params or return values. +- **A `.sql` file named `*.test.sql` never becomes an endpoint** — `SqlFileSource.SkipPattern` excludes test files by default. +- **Test HTTP block paths must be the FULL path** including `UrlPathPrefix` (`/api/...`) — a bare `/get-users` is a 404 with a warning. +- **`ResponseTempTable.DebugTable` is a dev-only debugging aid** — never enable in CI; combine with `Keep: true` when the run drops its test database. - After changing an annotation, re-run with `--log:minimallevels:npgsqlrest=debug` (or `--validate`) to confirm it parsed as intended. diff --git a/.claude/skills/npgsqlrest/annotations-reference.md b/.claude/skills/npgsqlrest/annotations-reference.md index d9c612ca..1dc513ad 100644 --- a/.claude/skills/npgsqlrest/annotations-reference.md +++ b/.claude/skills/npgsqlrest/annotations-reference.md @@ -1,6 +1,6 @@ # NpgsqlRest — Full Annotation Reference -Every comment annotation, generated from `npgsqlrest --annotations` (NpgsqlRest v3.18.1). +Every comment annotation, generated from `npgsqlrest --annotations` (NpgsqlRest v3.19.0). Annotations apply to both endpoint sources (database routines via `comment on`, and `.sql` files via leading `--` comments). The `@` prefix is optional. Regenerate with `npgsqlrest --annotations`. ## `http` @@ -188,9 +188,9 @@ Enable file upload for this endpoint, optionally specifying upload handlers. ## `param` - **Aliases:** parameter, param -- **Syntax:** `param is hash of | param is upload metadata | param [type] | param is [type]` +- **Syntax:** `param is hash of | param is upload metadata | param [type] | param is [type] | param type is | param default ` -Configure parameter behavior: hash computation, upload metadata binding, or rename/retype. Rename forms: 'param $1 user_id', 'param $1 user_id integer', 'param $1 is user_id', 'param _old_name better_name'. Works on all endpoint types. +Configure parameter behavior: hash computation, upload metadata binding, rename/retype, retype WITHOUT rename ('param user_id type is integer'), or default value ('param user_id default null'). Rename forms: 'param $1 user_id', 'param $1 user_id integer', 'param $1 is user_id', 'param _old_name better_name'. In SQL files with named (:name) placeholders the parameter is addressed by the placeholder's own name (a leading ':' is tolerated). Works on all endpoint types. ## `sse_path` @@ -352,4 +352,3 @@ Rename a result key in multi-command SQL file responses. N is the 1-based comman - **Syntax:** `define_param [type]` Define a virtual parameter that exists for HTTP matching and claim mapping but is NOT bound to the PostgreSQL command. Useful for SQL file endpoints where you need parameters for comment placeholders or claim mapping without referencing them in SQL. Default type is text. SQL file source only. - diff --git a/.claude/skills/npgsqlrest/configuration-reference.jsonc b/.claude/skills/npgsqlrest/configuration-reference.jsonc index f986c9e0..49f8dd89 100644 --- a/.claude/skills/npgsqlrest/configuration-reference.jsonc +++ b/.claude/skills/npgsqlrest/configuration-reference.jsonc @@ -1,5 +1,5 @@ // NpgsqlRest — FULL configuration reference (every option, with inline comments). -// Generated verbatim from: npgsqlrest --config (NpgsqlRest v3.18.1) +// Generated verbatim from: npgsqlrest --config (NpgsqlRest v3.19.0) // This is the source of truth for configuration. Regenerate with: npgsqlrest --config { @@ -885,11 +885,14 @@ // // See https://github.com/serilog/serilog/wiki/Configuration-Basics#minimum-level // Verbose, Debug, Information, Warning, Error, Fatal. + // Set a level to "Off" (aliases "None"/"Silent") to mute that logger entirely; use null (or omit it) to fall back to its built-in default. // Note: NpgsqlRest logger applies to main application logger, which will, by default have the name defined in the ApplicationName setting. + // NpgsqlRestTest is the SQL test runner (--test) channel (see TestRunner:LoggerName): discovery/parsing at Debug, each query and HTTP call at Verbose, notices by severity. // "MinimalLevels": { "NpgsqlRest": "Information", "NpgsqlRestClient": "Information", + "NpgsqlRestTest": "Information", "System": "Warning", "Microsoft": "Warning" }, @@ -1426,6 +1429,173 @@ "ActivityPath": "/stats/activity" }, + // + // SQL test runner. Invoked with the `--test` command-line flag (this section is otherwise inert). + // Discovers *.test.sql files, runs each in an isolated non-pooled connection, can invoke endpoints + // in-process from an embedded `/* GET /path */` block (response captured into a temp table), and + // asserts via boolean-returning SELECTs or `do $$ ... assert ... $$;` blocks. Exit codes: + // 0 pass, 1 failures, 2 errors, 3 config/runner error, 4 no tests found. + // + "TestRunner": { + // + // Glob (same engine as SqlFileSource) selecting test files. Empty disables discovery. Two layouts: + // co-located (app.sql next to app.test.sql — SqlFileSource SkipPattern keeps tests out of the endpoints) + // or a separate tests tree (e.g. "./tests/**/*.test.sql"). + // + "FilePattern": "", + // + // Optional filter narrowing the discovered set — the fast path for iterating on one test: + // npgsqlrest ... --test --testrunner:filter=login + // Matched against each file's cwd-relative path: a value without wildcards is a substring match; + // with wildcards it is the same glob engine as FilePattern. Empty = run everything discovered. + // + "Filter": "", + // + // Tag filtering (comma- or whitespace-separated lists; case-insensitive). A test file declares tags + // with a `-- @tag name [name ...]` header annotation. "Tag" runs only files carrying at least one of + // the listed tags; "ExcludeTag" skips files carrying any of them (exclude wins). Composes with Filter. + // npgsqlrest ... --test --testrunner:tag=smoke --testrunner:excludetag=slow + // + "Tag": "", + "ExcludeTag": "", + // + // Optional: a ConnectionStrings entry to run the tests against instead of the app's main connection. In + // test mode it becomes the connection used for endpoint type-checking (Describe) and execution, so it can + // point at a dedicated test database that a Setup step creates first (it need not exist at startup). + // Empty = use the main connection. Tip: {rnd1}..{rnd10} are random lowercase tokens (length = the digit), + // stable for the whole run, usable in any connection string or Setup/Teardown SQL (e.g. Database=app_test_{rnd6}). + // Need several distinct tokens of the same length? Indexed instances {rndN_1}..{rndN_9} are each independent. + // + "ConnectionName": "", + // + // Max test files run concurrently. 0 => processor count. Each test uses its own non-pooled connection. + // + "MaxParallelism": 0, + // + // Stop scheduling new tests after the first failure/error (in-flight tests still finish). + // + "FailFast": false, + // + // Per-test timeout. Accepts "30s", "5m", "1h", a plain number of seconds, or "hh:mm:ss". 0 disables. + // + "PerTestTimeout": "30s", + // + // Optional path to also write a JUnit XML report (console output is always printed). + // + "JUnitOutput": null, + // + // Skip Teardown so a failed run's state can be inspected. + // + "Keep": false, + // + // Detailed console REPORT: list passed assertions, print the full failing SQL statement, and show + // captured `raise notice` output for passing tests too. This shapes the report only — for diagnostic + // logging of every executed query/HTTP call, raise the log channel instead (Log:MinimalLevels + LoggerName). + // + "DetailedReport": false, + // + // Treat "no tests discovered" as success (exit 0) instead of exit 4. + // + "AllowEmpty": false, + // + // Endpoint-coverage summary after the run: exercised N of M testable endpoints + the list of untested + // ones (endpoint kinds the runner rejects — SSE, upload, login/logout, outbound proxy — are excluded + // from the ratio and counted separately). Tri-state: null (default) reports after FULL runs but stays + // quiet when the run is narrowed by Filter/Tag (a deliberately partial run would just nag); true always + // reports; false never. CoverageThreshold (0-100) always reports and fails an otherwise-passing run + // with exit 2 when coverage is below it — CI gating for "every endpoint has a test". + // + "Coverage": null, + "CoverageThreshold": null, + // + // SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels. + // Discovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. + // + "LoggerName": "NpgsqlRestTest", + // + // Per-HTTP-block temp table that captures the response. Each block gets its own fresh temp table + // (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. + // A file with ONE HTTP block uses "Name"; a file with 2+ blocks uses "MultiNamePattern" where {n} is + // the 1-based block ordinal (_response_1, _response_2, ...). Per-block override: `# @response `. + // A null/empty column name omits that column. "DebugTable" (e.g. "_responses_debug"): ALSO mirror every + // response into a PERMANENT table for post-run inspection — survives rollbacks, holds the last run, one + // row per HTTP block with test_file/block/method/path metadata (debugging aid; do not enable in CI). + // + "ResponseTempTable": { + "Name": "_response", + "MultiNamePattern": "_response_{n}", + "DebugTable": null, + "Columns": { + "Status": "status", + "Body": "body", + "ContentType": "content_type", + "Headers": "headers", + "IsSuccess": "is_success" + } + }, + // + // Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in + // Setup/Teardown below, or from an individual test file's leading header comments: + // -- @setup StepName [StepName ...] runs before that file + // -- @teardown StepName [StepName ...] runs after that file (always, best-effort) + // -- @connection Name runs that file on a named ConnectionStrings entry + // Annotations are repeatable; names may be whitespace- or comma-separated and run in the order written. + // Test files can also reuse SQL scripts in place with psql-style includes: `\i file` (cwd-relative) or + // `\ir file` (relative to the including file) — executed on the test's connection, inside its transaction. + // + // The entries below are disabled EXAMPLES showing every step property ("Sql", "SqlFile", "Command", + // "WorkingDirectory", "ConnectionName") across the typical scenarios — flip "Enabled" to true (and adjust + // names, paths, and connections) instead of typing them from scratch. A step with "Enabled": false is + // simply IGNORED wherever it is referenced. + // + "Steps": { + "CreateTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "create database app_test_{rnd5}" }, + "DropTestDatabase": { "Enabled": false, "ConnectionName": "Admin", "Sql": "drop database if exists app_test_{rnd5} with (force)" }, + "ApplySchema": { "Enabled": false, "SqlFile": "./migrations/schema.sql" }, + "RunMigrationTool": { "Enabled": false, "Command": "echo replace with your migration tool command", "WorkingDirectory": "." }, + "StartDockerPostgres": { "Enabled": false, "Command": "docker run -d --name npgsqlrest-test-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres" }, + "StopDockerPostgres": { "Enabled": false, "Command": "docker rm -f npgsqlrest-test-pg" } + }, + // + // Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written. Each entry is a step + // NAME from "Steps" above, or an inline step object: + // { "Command": "...", "WorkingDirectory": "..." } — runs via the OS shell + // { "Sql": "..." } | { "SqlFile": "..." } — runs on the test connection; set "ConnectionName" + // to run on another ConnectionStrings entry (e.g. + // an admin connection that runs `create database`). + // + "Setup": [], + // + // Run-once teardown, ALWAYS (best-effort), in the EXACT order written. `Keep` skips it. Same entries as Setup. + // + "Teardown": [] + }, + + // + // Watch mode (interactive/dev-only) — one feature, two flavors: with --test it re-runs tests on + // changes (a changed test file re-runs alone; a changed endpoint file or database routine rebuilds + // endpoints in-process and re-runs everything; teardown runs once, on exit); without --test it + // supervises the SERVER and restarts it on SQL file source, configuration, and database routine + // changes. In both flavors a broken SQL file cannot kill the session (SqlFileSource ErrorMode is + // forced from Exit to Skip while watching). + // + "Watch": { + // + // Turn watch mode on. The --watch command line flag is the shorthand for this setting. + // + "Enabled": false, + // + // Poll the database for routine changes and restart the server (server watch) or rebuild endpoints and + // re-run the tests (test watch). The poll runs the SAME routine discovery query the endpoint source + // uses (same configured filters), hashed server-side into one value — so it detects exactly what + // changes discovered endpoints: functions/procedures (create/replace/drop/alter, grants), their + // COMMENT ON annotations, and the composite types and tables their signatures use; anything the + // discovery does not read can never trigger. Accepts "2s", "500ms", "1m", a plain number of seconds, + // or "hh:mm:ss"; 0 disables. One query per interval on a dedicated non-pooled connection. + // + "DatabasePollingInterval": "2s" + }, + // // Command retry strategies and options for client and middleware commands. // @@ -2461,7 +2631,11 @@ // // Set to true to overwrite existing files. // - "FileOverwrite": true + "FileOverwrite": true, + // + // When true, parameters filled by the server and not settable by the client are omitted from the generated HTTP file's query string and request body. Covers optional automatic parameters: HTTP Custom Type fields, resolved-parameter expressions, upload metadata, and (on endpoints using user parameters) IP-address and user-claim parameters. Default is false. + // + "OmitAutomaticParameters": false }, // @@ -2563,7 +2737,11 @@ // documented. Anonymous endpoints — typically health, login, probes — are omitted. Useful // for partner-facing documents. Default false = document everything. // - "RequiresAuthorizationOnly": false + "RequiresAuthorizationOnly": false, + // + // When true, parameters filled by the server and not settable by the client are omitted from documented query parameters and request bodies. Covers optional automatic parameters: HTTP Custom Type fields, resolved-parameter expressions, upload metadata, and (on endpoints using user parameters) IP-address and user-claim parameters. Default is false. + // + "OmitAutomaticParameters": false }, // // Enable or disable the MCP (Model Context Protocol) server endpoint. Disabled by default. Tools are NEVER auto-exposed: only routines explicitly opted in with the `mcp` comment annotation become MCP tools. Implements MCP specification 2025-11-25. @@ -2763,7 +2941,11 @@ // // TypeScript type for error response. Only used when IncludeStatusCode is true. // - "ErrorType": "{status: number; title: string; detail?: string | null} | undefined" + "ErrorType": "{status: number; title: string; detail?: string | null} | undefined", + // + // When true, parameters filled by the server and not settable by the client are omitted from the generated request interface, query string, and body. Covers optional automatic parameters: HTTP Custom Type fields, resolved-parameter expressions, upload metadata, and (on endpoints using user parameters) IP-address and user-claim parameters. Default is false. + // + "OmitAutomaticParameters": false }, // @@ -2877,7 +3059,11 @@ // // When true, for upload endpoints marked as proxy, the raw multipart/form-data content is forwarded directly to the upstream proxy instead of being processed locally. This allows the upstream service to handle file uploads. When false (default), upload endpoints with proxy annotation will process uploads locally and upload metadata will not be available to the proxy. // - "ForwardUploadContent": false + "ForwardUploadContent": false, + // + // Maximum length (characters) of a single automatic parameter value appended to the proxy upstream query string. Server-filled values (claims, IP, HTTP Custom Type fields, resolved-parameter expressions) longer than this are skipped with a warning instead of producing an unusable request line (HTTP 414/431). 0 or less disables the guard. To forward a large value, use a body-carrying proxy method (POST/PUT/PATCH). Default is 2048. + // + "MaxForwardedQueryParamLength": 2048 }, // @@ -2896,6 +3082,12 @@ // "FilePattern": "", // + // Glob (same semantics as FilePattern) for files to EXCLUDE from endpoint discovery. + // Default "*.test.sql" so co-located SQL test files (run by the test runner, see "TestRunner") + // are never exposed as endpoints. Empty string disables the exclusion. + // + "SkipPattern": "*.test.sql", + // // How comment annotations are processed for SQL file endpoints. // Possible values: Ignore, ParseAll, OnlyAnnotated, OnlyWithHttpTag. // OnlyAnnotated (default; OnlyWithHttpTag is a back-compat alias) requires an explicit diff --git a/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs b/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs index a9544173..0ba4d522 100644 --- a/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs +++ b/NpgsqlRest/Defaults/CommentParsers/AnnotationReference.cs @@ -233,8 +233,8 @@ static JsonArray ToJsonArray(string[] values) { ["name"] = "param", ["aliases"] = ToJsonArray(ParameterKey), - ["syntax"] = "param is hash of | param is upload metadata | param [type] | param is [type]", - ["description"] = "Configure parameter behavior: hash computation, upload metadata binding, or rename/retype. Rename forms: 'param $1 user_id', 'param $1 user_id integer', 'param $1 is user_id', 'param _old_name better_name'. Works on all endpoint types." + ["syntax"] = "param is hash of | param is upload metadata | param [type] | param is [type] | param type is | param default ", + ["description"] = "Configure parameter behavior: hash computation, upload metadata binding, rename/retype, retype WITHOUT rename ('param user_id type is integer'), or default value ('param user_id default null'). Rename forms: 'param $1 user_id', 'param $1 user_id integer', 'param $1 is user_id', 'param _old_name better_name'. In SQL files with named (:name) placeholders the parameter is addressed by the placeholder's own name (a leading ':' is tolerated). Works on all endpoint types." }); annotations.Add((JsonNode)new JsonObject diff --git a/README.md b/README.md index e5d2c3c0..badc4453 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build, Test, Publish and Release](https://github.com/NpgsqlRest/NpgsqlRest/actions/workflows/build-test-publish.yml/badge.svg)](https://github.com/NpgsqlRest/NpgsqlRest/actions/workflows/build-test-publish.yml) [![Tests](https://github.com/NpgsqlRest/NpgsqlRest/actions/workflows/test.yml/badge.svg)](https://github.com/NpgsqlRest/NpgsqlRest/actions/workflows/test.yml) -![1800+ integration tests](https://img.shields.io/badge/integration%20tests-1837%2B-brightgreen) +![2300+ integration tests](https://img.shields.io/badge/integration%20tests-2394%2B-brightgreen) ![License](https://img.shields.io/badge/license-MIT-green) ![GitHub Stars](https://img.shields.io/github/stars/NpgsqlRest/NpgsqlRest?style=social) ![GitHub Forks](https://img.shields.io/github/forks/NpgsqlRest/NpgsqlRest?style=social) @@ -11,9 +11,9 @@ **Automatic REST API for PostgreSQL** | [6.1x faster than PostgREST](https://npgsqlrest.github.io/blog/postgresql-rest-api-benchmark-2026.html) -> SQL files and PostgreSQL objects become REST endpoints. TypeScript clients are generated automatically. +> SQL files and PostgreSQL objects become REST endpoints. TypeScript clients are generated automatically. Tests are SQL files too. -**4,500+ req/s** on a single host · **1,800+ integration tests** · **12K LOC SQL** in production · **MIT** licensed +**4,500+ req/s** on a single host · **2,394 integration tests** · **12K LOC SQL** in production · **MIT** licensed *"Simplicity is the ultimate sophistication."* — Leonardo da Vinci @@ -42,38 +42,45 @@ Write a SQL file: -- sql/process_order.sql -- HTTP POST -- @authorize admin --- @param $1 order_id -- @result validate -select count(*) as found from orders where id = $1; -update orders set status = 'processing' where id = $1; +-- @single +select count(*) as found from orders where id = :order_id; +update orders set status = 'processing' where id = :order_id; -- @result confirm -select id, status from orders where id = $1; +select id, status from orders where id = :order_id; ``` That gives you `POST /api/process-order`: ```json -{"validate": [1], "result2": 1, "confirm": [{"id": 42, "status": "processing"}]} +{"validate": 1, "result2": 1, "confirm": [{"id": 42, "status": "processing"}]} ``` And a generated TypeScript client with full type safety: ```typescript -export async function processOrder(orderid: number) : Promise<{ - validate: number[], - result2: number, - confirm: { id: number, status: string }[] -}> { +interface IProcessOrderResponse { + validate: number; + result2: number; + confirm: { id: number, status: string }[]; +} + +export async function processOrder( + request: IProcessOrderRequest +) : Promise> { const response = await fetch(baseUrl + "/api/process-order", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ orderid }), + body: JSON.stringify(request) }); - return await response.json(); + return { + status: response.status, + response: response.ok ? await response.json() as IProcessOrderResponse : undefined!, + error: !response.ok && response.headers.get("content-length") !== "0" ? await response.json() as ApiError : undefined + }; } ``` -No framework, no ORM, no boilerplate. Authorization, parameters, type safety — from a SQL file. +No framework, no ORM, no boilerplate. The named placeholder `:order_id` — used three times, bound once — *is* the API parameter, and every result key is typed, down to the `@single` scalar and the column shapes. `ApiResult` carries the status and a typed error, so failures are type-safe too. ## Endpoint Sources @@ -107,9 +114,51 @@ where $1 is null or department_id = $1; Same pattern for PostgreSQL functions via `comment on function ... is '...'`. No middleware to register, no decorators, no controllers — the SQL is the source of truth. See [all annotations](https://npgsqlrest.github.io/annotations/). +## Tests Are SQL Files Too + +No test framework, no running server, no mocks. `npgsqlrest --test` invokes the **real endpoint pipeline in-process, on the test's own transaction** — insert fixtures, call the endpoint (it sees your uncommitted rows), assert with SQL, roll back: + +```sql +-- tests/get_users.test.sql +begin; + +insert into users (email) values ('fixture@example.com'); + +/* +GET /api/get-users +# @claim user_id=1 +*/ +select status = 200, 'authenticated caller gets 200' from _response; +select body::jsonb @> '[{"email": "fixture@example.com"}]', 'fixture is listed' from _response; + +rollback; +``` + +``` +npgsqlrest ./config.json --test + +PASS tests/get_users.test.sql (2 assertions, 52ms) +19 passed, 0 failed, 0 error(s) — 19 assertions in 9 files +endpoint coverage: 2/2 (100%) +``` + +Parallel isolated connections, throwaway test databases (`create database app_test_{rnd5}` as a plain setup step), per-test database clones, tags, JUnit XML, and **endpoint coverage** with a CI threshold gate — [Testing Guide](https://npgsqlrest.github.io/guide/testing). + +## Watch Mode + +```sh +npgsqlrest ./config.json --watch +``` + +Save a `.sql` file and the running API restarts with the change (~1s). `create or replace` a function in psql and the endpoint is live seconds later — **the database itself is watched**, annotations included. The TypeScript client regenerates every cycle, so your frontend types follow your SQL as you type. Add `--test` and it becomes a millisecond test loop instead — a changed test re-runs alone; a changed endpoint rebuilds in-process. [Watch Mode](https://npgsqlrest.github.io/config/watch). + ## Features - **Multi-command SQL scripts** — multiple statements in one file execute as a batch, returning named result sets +- **Named parameters in SQL files** — `where email = :email`; the placeholder is the API parameter, repeated names bind once +- **SQL test runner** (`--test`) — tests as plain SQL files, in-process endpoint invocation inside the test's transaction, throwaway test databases, JUnit XML, endpoint-coverage gating +- **Watch mode** (`--watch`) — restart on SQL file, configuration, and database routine changes; test watch re-runs in milliseconds +- **MCP server** — `@mcp` exposes any endpoint as a Model Context Protocol tool for AI agents, same auth and rate limits - **TypeScript/JS code generation** and `.http` files — types flow from PostgreSQL to your frontend - **AOT-compiled native binaries** — zero dependencies, instant startup - [**6.1x faster than PostgREST**](https://npgsqlrest.github.io/blog/postgresql-rest-api-benchmark-2026.html) at 100 concurrent users @@ -142,7 +191,7 @@ Same pattern for PostgreSQL functions via `comment on function ... is '...'`. No ## Claude Code skill -A [Claude Code](https://claude.com/claude-code) skill that teaches the agent how to build with NpgsqlRest — both endpoint sources (database functions/procedures/tables/views and `.sql` files), the full annotation set, configuration, HTTP custom types, proxy, caching, auth, SSE, and MCP. It lives in [`.claude/skills/npgsqlrest/`](.claude/skills/npgsqlrest/) and bundles a complete annotation reference and a full annotated `appsettings.json`. +A [Claude Code](https://claude.com/claude-code) skill that teaches the agent how to build with NpgsqlRest — both endpoint sources (database functions/procedures/tables/views and `.sql` files), the full annotation set, configuration, HTTP custom types, proxy, caching, auth, SSE, MCP, the SQL test runner (`--test`), and watch mode (`--watch`). It lives in [`.claude/skills/npgsqlrest/`](.claude/skills/npgsqlrest/) and bundles a complete annotation reference and a full annotated `appsettings.json`. Install it for your own NpgsqlRest projects — per-user (available everywhere): @@ -158,7 +207,7 @@ done ## About -NpgsqlRest is built and maintained by [Vedran Bilopavlović](https://www.linkedin.com/in/vb-software/). The C# library, parser, codegen, and runtime are hand-written, covered by 1,800+ integration tests, and battle-tested in production. +NpgsqlRest is built and maintained by [Vedran Bilopavlović](https://www.linkedin.com/in/vb-software/). The C# library, parser, codegen, and runtime are hand-written, covered by 2,394 integration tests, and battle-tested in production. ## Contributing diff --git a/changelog/v3.19.0.md b/changelog/v3.19.0.md index 2f64d0e9..f93848de 100644 --- a/changelog/v3.19.0.md +++ b/changelog/v3.19.0.md @@ -26,10 +26,12 @@ insert into app.users (id, email, name) values (100, 'x@example.com', 'Fixture') GET /api/get-users # @claim user_id=1 */ -select (select status from _response) = 200, - 'authenticated caller gets 200'; -select (select body::jsonb @> '[{"email": "x@example.com"}]' from _response), - 'the fixture user is listed'; +select status = 200, + 'authenticated caller gets 200' +from _response; +select body::jsonb @> '[{"email": "x@example.com"}]', + 'the fixture user is listed' +from _response; rollback; ``` @@ -119,8 +121,9 @@ Each HTTP block's response is captured into its own **temp table** on the test's Naming: a file with **one** HTTP block uses `ResponseTempTable.Name` (default **`_response`**); a file with **two or more** uses `ResponseTempTable.MultiNamePattern` (default **`_response_{n}`**) where `{n}` is the block's 1-based position — `_response_1`, `_response_2`, … A block with `# @response name` uses that name instead (it still counts in the numbering of the others). Column names are configurable; setting one to null/empty omits that column. ```sql -select (select body::jsonb ->> 'email' from _response) = 'grace@example.com', - 'email is normalized to lowercase'; +select body::jsonb ->> 'email' = 'grace@example.com', + 'email is normalized to lowercase' +from _response; ``` **Debugging captured responses** — temp tables vanish with the test's rollback and connection, so you cannot inspect them afterwards (and re-issuing the request from an `.http` file cannot reproduce a response that depended on the test's uncommitted fixtures). Set **`ResponseTempTable.DebugTable`** (e.g. `"_responses_debug"`; default `null` = off) and every captured response is **also mirrored into a permanent table** — written on a separate autocommit connection, immune to rollbacks, recreated at the start of every run so it always holds the last run. One table covers everything: each HTTP block adds one row, with `test_file`, `block` (that block's response-table name — `_response`, a `_response_{n}` ordinal, or the `# @response` name), `method`, `path`, `status`, `body`, `content_type`, `headers`, `is_success`, and `captured_at` — so after a run you can open a query editor and dig into any response with jsonb operators. The temp-table semantics are unchanged; enabling it prints a loud warning (debugging aid — do not enable in CI). In the fresh-test-database workflow combine it with `Keep: true`, or teardown drops the database and the mirror with it. @@ -136,7 +139,7 @@ begin; \i ./shared/reset_counters.sql -- path relative to the cwd, like every other configured path /* GET /api/get-users */ -select (select jsonb_array_length(body::jsonb) from _response) = 5, 'seed + fixture users listed'; +select jsonb_array_length(body::jsonb) = 5, 'seed + fixture users listed' from _response; rollback; ``` @@ -255,7 +258,7 @@ Results are **per assertion** (like pgTAP/xUnit — each boolean `SELECT` / DO b PASS tests/login_succeeds.test.sql (3 assertions, 50ms) FAIL tests/get_users.test.sql (49ms) ✗ the caller is excluded — 2 of 3 users listed [tests/get_users.test.sql:17] - select (select jsonb_array_length(body::jsonb) from _response) = 2, … + select jsonb_array_length(body::jsonb) = 2, … 18 passed, 1 failed, 0 error(s) — 19 assertions in 9 files ``` diff --git a/docker/Dockerfile.jit b/docker/Dockerfile.jit index 5b0ce475..8fe063c1 100644 --- a/docker/Dockerfile.jit +++ b/docker/Dockerfile.jit @@ -24,10 +24,13 @@ COPY NpgsqlRest/ NpgsqlRest/ # Shared source compiled into NpgsqlRest.csproj ( + global # usings). Without this the JIT build fails: NpgsqlRest.GlobalUsings.g.cs -> CS0234 'NpgsqlRest.Common'. COPY NpgsqlRest.Common/ NpgsqlRest.Common/ +# NOTE: `COPY NpgsqlRestClient/*.cs` is NOT recursive — every SUBFOLDER of NpgsqlRestClient must be +# copied explicitly below, or the JIT build fails with CS0234/CS0246 (this bit Fido2 and Testing already). COPY NpgsqlRestClient/*.cs NpgsqlRestClient/ COPY NpgsqlRestClient/*.csproj NpgsqlRestClient/ COPY NpgsqlRestClient/appsettings.json NpgsqlRestClient/ COPY NpgsqlRestClient/Fido2/ NpgsqlRestClient/Fido2/ +COPY NpgsqlRestClient/Testing/ NpgsqlRestClient/Testing/ COPY plugins/ plugins/ # Build and publish diff --git a/npm/package.json b/npm/package.json index 6cefc2e8..53675f0e 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.18.2", + "version": "3.19.0", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/version.txt b/version.txt index 05d97541..209f5795 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.18.2 \ No newline at end of file +3.19.0 \ No newline at end of file