From 25c56809d51de4b063ddadca8711e48fc0535ac7 Mon Sep 17 00:00:00 2001 From: Hildebrando Chavez Date: Fri, 3 Apr 2026 15:36:03 -0600 Subject: [PATCH 1/2] feat: add Connection String authentication mode - Implemented a new authentication mode allowing users to input a full ADO.NET connection string. - Updated the UI to include a textarea for the connection string input. - Enhanced validation to ensure the connection string is provided when this mode is selected. - Adjusted the connection settings and recent connections models to accommodate the new authentication mode. - Updated tests to cover scenarios for the new connection string mode. - Modified the changelog and README to reflect the new feature. --- README.md | 22 +- .../JsonRpcServer.cs | 71 ++++- .../Models/RecentConnectionDto.cs | 9 + .../Models/SaveRecentConnectionRequest.cs | 9 + .../Data/SqliteContext.cs | 19 +- .../Enums/AuthenticationMode.cs | 1 + .../AuthenticationModeExtensions.cs | 3 + .../Models/Connection.cs | 24 +- .../Repositories/ConnectionRepository.cs | 282 ++++++++++++------ .../JsonRpcServerTests.cs | 107 +++++++ vscode-extension/CHANGELOG.md | 10 + vscode-extension/README.md | 1 + vscode-extension/package.json | 4 +- .../src/models/authentication-mode.ts | 12 + .../src/models/connection-settings.ts | 36 ++- .../src/models/recent-connection.ts | 6 + .../test/suite/connection-settings.test.ts | 258 ++++++++++++++-- .../src/views/profiler-panel-provider.ts | 103 ++++++- .../recent-connections-panel-provider.ts | 1 + 19 files changed, 819 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 2a7c3b2..4768ba6 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ [![.NET Version](https://img.shields.io/badge/.NET-10.0-blue)](https://dotnet.microsoft.com/) [![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/brandochn.light-query-profiler?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/brandochn.light-query-profiler)](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) +[![Open VSX Registry](https://img.shields.io/open-vsx/v/brandochn/light-query-profiler?label=Open%20VSX%20Registry)](https://open-vsx.org/extension/brandochn/light-query-profiler) -> A lightweight query profiler for SQL Server and Azure SQL Database — available as a desktop application and as a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler). +> A lightweight query profiler for SQL Server and Azure SQL Database — available as a desktop application and as a VS Code extension on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) and the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler). ## Table of Contents @@ -55,13 +56,15 @@ without re-entering credentials. ## VS Code Extension -Light Query Profiler is available on the **Visual Studio Code Marketplace**: +Light Query Profiler is available on the **Visual Studio Code Marketplace** and the **Open VSX Registry**: **[Install from the VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)** +**[Install from the Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler)** + ### Getting Started with the VS Code Extension -1. Install the extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) +1. Install the extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) or the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler) 2. Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) 3. Run **Light Query Profiler: Show SQL Profiler** 4. Enter your connection details: @@ -92,11 +95,12 @@ The exported JSON format is compatible between the VS Code extension and the des ## Authentication Modes -| Mode | Description | -| ------------------------- | -------------------------------------------------------- | -| Windows Authentication | Uses the current Windows user credentials (Windows only) | -| SQL Server Authentication | Username and password | -| Azure Active Directory | Azure AD authentication for Azure SQL Database | +| Mode | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Windows Authentication | Uses the current Windows user credentials (Windows only) | +| SQL Server Authentication | Username and password | +| Azure Active Directory | Azure AD authentication for Azure SQL Database | +| Connection String | Provide a full ADO.NET connection string. Supports any valid SQL Server or Azure SQL Database connection string. | --- @@ -119,7 +123,7 @@ The project runs on **Windows**, **Linux**, and **macOS**, provided .NET 10 is i This project is actively under development. Stable releases are already available for both the desktop application and the VS Code extension: -- **VS Code Extension** — available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) +- **VS Code Extension** — available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) and the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler) - **Desktop App** — available on the [GitHub Releases](https://github.com/brandochn/LightQueryProfiler/releases) page Contributions are always welcome — feel free to open an issue or submit a pull request. diff --git a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs index e416074..16e0bec 100644 --- a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs +++ b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs @@ -7,6 +7,7 @@ using LightQueryProfiler.Shared.Repositories.Interfaces; using LightQueryProfiler.Shared.Services; using LightQueryProfiler.Shared.Services.Interfaces; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using StreamJsonRpc; @@ -21,6 +22,7 @@ public class JsonRpcServer private readonly Dictionary _activeSessions; private readonly Dictionary _activeContexts; private readonly IConnectionRepository _connectionRepository; + private readonly IDatabaseEngineDetector _engineDetector; public JsonRpcServer(ILogger logger) { @@ -31,13 +33,14 @@ public JsonRpcServer(ILogger logger) _connectionRepository = new ConnectionRepository( new SqliteContext(), new AesGcmPasswordProtectionService()); + _engineDetector = new DatabaseEngineDetector(); } /// /// Internal constructor for unit testing — allows injection of a mock repository /// without requiring a real SQLite database on disk. /// - internal JsonRpcServer(ILogger logger, IConnectionRepository connectionRepository) + internal JsonRpcServer(ILogger logger, IConnectionRepository connectionRepository, IDatabaseEngineDetector? engineDetector = null) { ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(connectionRepository); @@ -45,6 +48,7 @@ internal JsonRpcServer(ILogger logger, IConnectionRepository conn _activeSessions = new Dictionary(); _activeContexts = new Dictionary(); _connectionRepository = connectionRepository; + _engineDetector = engineDetector ?? new DatabaseEngineDetector(); } /// @@ -71,7 +75,8 @@ public async Task StartProfilingAsync(StartProfilingRequest request, Cancellatio throw new ArgumentException("ConnectionString cannot be null or empty", nameof(request)); } - if (!Enum.IsDefined(typeof(DatabaseEngineType), request.EngineType)) + // Allow 0 (auto-detect for ConnectionString mode) or a defined DatabaseEngineType value. + if (request.EngineType != 0 && !Enum.IsDefined(typeof(DatabaseEngineType), request.EngineType)) { throw new ArgumentException($"Invalid EngineType: {request.EngineType}", nameof(request)); } @@ -83,31 +88,40 @@ public async Task StartProfilingAsync(StartProfilingRequest request, Cancellatio request.SessionName, request.EngineType); } - try { - // Create context and services for this session var dbContext = new ApplicationDbContext(request.ConnectionString); + + DatabaseEngineType effectiveEngineType; + if (request.EngineType == 0) + { + effectiveEngineType = await _engineDetector.DetectEngineTypeAsync(dbContext, cancellationToken) + .ConfigureAwait(false); + } + else + { + effectiveEngineType = (DatabaseEngineType)request.EngineType; + } + var xEventRepository = new XEventRepository(dbContext); - xEventRepository.SetEngineType((DatabaseEngineType)request.EngineType); + xEventRepository.SetEngineType(effectiveEngineType); var xEventService = new XEventService(); var profilerService = new ProfilerService(xEventRepository, xEventService); - // Create template based on engine type - var template = ProfilerSessionTemplateFactory.CreateTemplate((DatabaseEngineType)request.EngineType); + var template = ProfilerSessionTemplateFactory.CreateTemplate(effectiveEngineType); - // Start profiling await Task.Run(() => profilerService.StartProfiling(request.SessionName, template), cancellationToken) .ConfigureAwait(false); - // Store for later use _activeSessions[request.SessionName] = profilerService; _activeContexts[request.SessionName] = dbContext; if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Profiling session started successfully: {SessionName}", request.SessionName); + _logger.LogInformation( + "Profiling session started successfully: {SessionName} with engine type: {EngineType}", + request.SessionName, effectiveEngineType); } } catch (Exception ex) @@ -307,6 +321,7 @@ public async Task> GetRecentConnectionsAsync( IntegratedSecurity = c.IntegratedSecurity, EngineType = c.EngineType.HasValue ? (int)c.EngineType.Value : null, AuthenticationMode = (int)c.AuthenticationMode, + ConnectionString = c.ConnectionString, }) .ToList(); } @@ -328,7 +343,41 @@ public async Task SaveRecentConnectionAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + // ── ConnectionString mode — MUST come before DataSource/InitialCatalog guards ── + if (request.AuthenticationMode.HasValue + && request.AuthenticationMode.Value == (int)AuthenticationMode.ConnectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.ConnectionString, nameof(request)); + + var builder = new SqlConnectionStringBuilder(request.ConnectionString); + var connection = new Connection( + id: 0, + initialCatalog: builder.InitialCatalog, + creationDate: DateTime.UtcNow, + dataSource: builder.DataSource, + integratedSecurity: builder.IntegratedSecurity, + password: null, + userId: string.IsNullOrEmpty(builder.UserID) ? null : builder.UserID, + engineType: null, + authenticationMode: AuthenticationMode.ConnectionString, + connectionString: request.ConnectionString); + + await _connectionRepository.UpsertAsync(connection).ConfigureAwait(false); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Recent connection saved (ConnString mode): DataSource={DataSource}, InitialCatalog={InitialCatalog}", + builder.DataSource, + builder.InitialCatalog); + } + + return; + } + + // ── Standard mode guards (unchanged) ────────────────────────────────── if (string.IsNullOrWhiteSpace(request.DataSource)) { throw new ArgumentException("DataSource cannot be null or empty", nameof(request)); @@ -339,8 +388,6 @@ public async Task SaveRecentConnectionAsync( throw new ArgumentException("InitialCatalog cannot be null or empty", nameof(request)); } - cancellationToken.ThrowIfCancellationRequested(); - try { var connection = new Connection( diff --git a/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs b/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs index 74ea09b..d1e98b1 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs @@ -16,5 +16,14 @@ public record RecentConnectionDto public bool IntegratedSecurity { get; init; } public int? EngineType { get; init; } public int? AuthenticationMode { get; init; } + + /// + /// Gets the plain-text ADO.NET connection string, decrypted by the repository layer before mapping. + /// + /// + /// Only populated when AuthenticationMode equals 3 (ConnectionString mode). + /// Never log this value — it may contain credentials. + /// + public string? ConnectionString { get; init; } } } diff --git a/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs b/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs index 2c41516..d6de8d5 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs @@ -15,5 +15,14 @@ public record SaveRecentConnectionRequest public bool IntegratedSecurity { get; init; } public int? EngineType { get; init; } public int? AuthenticationMode { get; init; } + + /// + /// Gets the plain-text ADO.NET connection string provided by the user. + /// + /// + /// Only set when AuthenticationMode equals 3 (ConnectionString mode). + /// The repository layer encrypts this value before storage. Never log this value. + /// + public string? ConnectionString { get; init; } } } diff --git a/src/LightQueryProfiler.Shared/Data/SqliteContext.cs b/src/LightQueryProfiler.Shared/Data/SqliteContext.cs index 5fbff7b..7c71938 100644 --- a/src/LightQueryProfiler.Shared/Data/SqliteContext.cs +++ b/src/LightQueryProfiler.Shared/Data/SqliteContext.cs @@ -37,7 +37,8 @@ Password NVARCHAR(100) NULL, IntegratedSecurity INTEGER NULL, CreationDate Date, EngineType INTEGER NULL, - AuthenticationMode INTEGER NULL + AuthenticationMode INTEGER NULL, + ConnectionString NVARCHAR(2000) NULL )"; SqliteCommand createTable = new(tableCommand, db); @@ -74,6 +75,22 @@ FROM pragma_table_info('Connections') SqliteCommand alterTableAuthMode = new(alterTableAuthModeCommand, db); await alterTableAuthMode.ExecuteNonQueryAsync(); } + + // Migration: Add ConnectionString column if it doesn't exist + const string addConnStringColumnCheck = @" + SELECT COUNT(*) as ColumnExists + FROM pragma_table_info('Connections') + WHERE name='ConnectionString'"; + + SqliteCommand checkConnStringColumn = new(addConnStringColumnCheck, db); + var connStringResult = await checkConnStringColumn.ExecuteScalarAsync(); + + if (connStringResult != null && Convert.ToInt32(connStringResult) == 0) + { + const string alterTableConnString = "ALTER TABLE Connections ADD COLUMN ConnectionString NVARCHAR(2000) NULL"; + SqliteCommand alterTableCmd = new(alterTableConnString, db); + await alterTableCmd.ExecuteNonQueryAsync(); + } } } } diff --git a/src/LightQueryProfiler.Shared/Enums/AuthenticationMode.cs b/src/LightQueryProfiler.Shared/Enums/AuthenticationMode.cs index f94c293..de52042 100644 --- a/src/LightQueryProfiler.Shared/Enums/AuthenticationMode.cs +++ b/src/LightQueryProfiler.Shared/Enums/AuthenticationMode.cs @@ -5,5 +5,6 @@ public enum AuthenticationMode WindowsAuth = 0, SQLServerAuth = 1, AzureSQLDatabase = 2, + ConnectionString = 3, } } diff --git a/src/LightQueryProfiler.Shared/Extensions/AuthenticationModeExtensions.cs b/src/LightQueryProfiler.Shared/Extensions/AuthenticationModeExtensions.cs index 9f123bc..213dc34 100644 --- a/src/LightQueryProfiler.Shared/Extensions/AuthenticationModeExtensions.cs +++ b/src/LightQueryProfiler.Shared/Extensions/AuthenticationModeExtensions.cs @@ -17,6 +17,9 @@ public static string GetString(this AuthenticationMode am) case AuthenticationMode.AzureSQLDatabase: return "Azure SQL Database"; + case AuthenticationMode.ConnectionString: + return "Connection String"; + default: return string.Empty; } diff --git a/src/LightQueryProfiler.Shared/Models/Connection.cs b/src/LightQueryProfiler.Shared/Models/Connection.cs index 2bb969a..733a134 100644 --- a/src/LightQueryProfiler.Shared/Models/Connection.cs +++ b/src/LightQueryProfiler.Shared/Models/Connection.cs @@ -4,7 +4,20 @@ namespace LightQueryProfiler.Shared.Models { public class Connection { - public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth) + /// + /// Initializes a new instance of . + /// + /// Primary key (0 for new entities). + /// Database name. + /// When the connection was last used. + /// Server address or named instance. + /// True when using Windows Authentication. + /// Plain-text password; encrypted by the repository layer before storage. + /// SQL Server or Azure AD login name. + /// Detected or specified engine type. when using (detected at start-profiling time). + /// Authentication method used. + /// Raw ADO.NET connection string. Only set when is . Plain-text — the repository layer decrypts it before passing here. Never log this value. + public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth, string? connectionString = null) { Id = id; InitialCatalog = initialCatalog; @@ -15,6 +28,7 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d UserId = userId; EngineType = engineType; AuthenticationMode = authenticationMode; + ConnectionString = connectionString; } public int Id { get; } @@ -37,5 +51,13 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d /// Gets the authentication mode used for this connection /// public AuthenticationMode AuthenticationMode { get; } + + /// + /// Gets the raw ADO.NET connection string entered by the user. + /// Only populated when is . + /// Value is plain-text — the repository layer decrypts it before setting this property. + /// + /// Never log this value — it may contain credentials. + public string? ConnectionString { get; } } } diff --git a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs index c21fe9f..515cd59 100644 --- a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs +++ b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs @@ -52,6 +52,12 @@ public ConnectionRepository(IDatabaseContext context, IPasswordProtectionService private string? DecryptPassword(string? storedPassword) => _passwordProtectionService?.Decrypt(storedPassword) ?? storedPassword; + private string? EncryptConnectionString(string? plainConnectionString) + => _passwordProtectionService?.Encrypt(plainConnectionString) ?? plainConnectionString; + + private string? DecryptConnectionString(string? storedConnectionString) + => _passwordProtectionService?.Decrypt(storedConnectionString) ?? storedConnectionString; + public async Task AddAsync(Connection entity) { await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); @@ -59,13 +65,13 @@ public async Task AddAsync(Connection entity) string? encryptedPassword = EncryptPassword(entity.Password); - // Try with EngineType column first + // Try with ConnectionString column first try { - const string sqlWithAuthMode = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode)"; + const string sqlWithConnString = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode, ConnectionString) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode, @ConnectionString)"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); @@ -74,17 +80,18 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); + sqliteCommand.Parameters.AddWithValue("@ConnectionString", (object?)EncryptConnectionString(entity.ConnectionString) ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without ConnectionString column try { - const string sqlWithEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType)"; + const string sqlWithAuthMode = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode)"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); @@ -92,22 +99,42 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column - const string sqlWithoutEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate)"; - - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); - sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); - sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); - sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); - sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); - sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); - sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); - await sqliteCommand.ExecuteNonQueryAsync(); + // Fallback for databases without AuthenticationMode column + try + { + const string sqlWithEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType)"; + + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); + sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); + sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); + sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); + sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + await sqliteCommand.ExecuteNonQueryAsync(); + } + catch (SqliteException) + { + // Fallback for databases without EngineType or AuthenticationMode column + const string sqlWithoutEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate)"; + + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); + sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); + sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); + sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); + sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); + await sqliteCommand.ExecuteNonQueryAsync(); + } } } } @@ -156,7 +183,8 @@ public async Task UpsertAsync(Connection entity) entity.Password, entity.UserId, entity.EngineType, - entity.AuthenticationMode); + entity.AuthenticationMode, + entity.ConnectionString); await UpdateAsync(updated); } } @@ -176,16 +204,16 @@ public async Task> GetAllAsync() { // SELECT column ordinals: // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, - // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode]] + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString]]] List connections = new List(); await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with AuthenticationMode column first + // Try with ConnectionString column first try { - const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); + const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString FROM Connections"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); while (query.Read()) @@ -193,6 +221,7 @@ public async Task> GetAllAsync() var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var storedConnectionString = query.IsDBNull(9) ? null : query.GetString(9); connections.Add(new Connection( query.GetInt32(0), query.GetString(1), @@ -202,21 +231,23 @@ public async Task> GetAllAsync() DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, - authModeValue)); + authModeValue, + connectionString: DecryptConnectionString(storedConnectionString))); } } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without ConnectionString column try { - const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); while (query.Read()) { var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); + var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); connections.Add(new Connection( query.GetInt32(0), @@ -226,28 +257,54 @@ public async Task> GetAllAsync() query.GetBoolean(4), DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), - engineTypeValue)); + engineTypeValue, + authModeValue)); } } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column - const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); - await using var query = await sqliteCommand.ExecuteReaderAsync(); - - while (query.Read()) + // Fallback for databases without AuthenticationMode column + try { - var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); - connections.Add(new Connection( - query.GetInt32(0), - query.GetString(1), - query.GetDateTime(2), - query.GetString(3), - query.GetBoolean(4), - DecryptPassword(storedPassword), - query.IsDBNull(6) ? null : query.GetString(6), - null)); + const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + await using var query = await sqliteCommand.ExecuteReaderAsync(); + + while (query.Read()) + { + var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connections.Add(new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue)); + } + } + catch (SqliteException) + { + // Fallback for databases without EngineType or AuthenticationMode column + const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); + await using var query = await sqliteCommand.ExecuteReaderAsync(); + + while (query.Read()) + { + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connections.Add(new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + null)); + } } } } @@ -259,16 +316,16 @@ public async Task GetByIdAsync(int id) { // SELECT column ordinals: // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, - // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode]] + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString]]] Connection? connection = null; await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with AuthenticationMode column first + // Try with ConnectionString column first try { - const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); + const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString FROM Connections WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -277,6 +334,7 @@ public async Task GetByIdAsync(int id) var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var storedConnectionString = query.IsDBNull(9) ? null : query.GetString(9); connection = new Connection( query.GetInt32(0), query.GetString(1), @@ -286,22 +344,24 @@ public async Task GetByIdAsync(int id) DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, - authModeValue); + authModeValue, + connectionString: DecryptConnectionString(storedConnectionString)); } } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without ConnectionString column try { - const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); while (query.Read()) { var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); + var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); connection = new Connection( query.GetInt32(0), @@ -311,29 +371,56 @@ public async Task GetByIdAsync(int id) query.GetBoolean(4), DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), - engineTypeValue); + engineTypeValue, + authModeValue); } } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column - const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); - sqliteCommand.Parameters.AddWithValue("@Id", id); - await using var query = await sqliteCommand.ExecuteReaderAsync(); - - while (query.Read()) + // Fallback for databases without AuthenticationMode column + try { - var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); - connection = new Connection( - query.GetInt32(0), - query.GetString(1), - query.GetDateTime(2), - query.GetString(3), - query.GetBoolean(4), - DecryptPassword(storedPassword), - query.IsDBNull(6) ? null : query.GetString(6), - null); + const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + sqliteCommand.Parameters.AddWithValue("@Id", id); + await using var query = await sqliteCommand.ExecuteReaderAsync(); + + while (query.Read()) + { + var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connection = new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue); + } + } + catch (SqliteException) + { + // Fallback for databases without EngineType or AuthenticationMode column + const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); + sqliteCommand.Parameters.AddWithValue("@Id", id); + await using var query = await sqliteCommand.ExecuteReaderAsync(); + + while (query.Read()) + { + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connection = new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + null); + } } } } @@ -353,11 +440,11 @@ public async Task UpdateAsync(Connection entity) string? encryptedPassword = EncryptPassword(entity.Password); - // Try with AuthenticationMode column first + // Try with ConnectionString column first try { - const string sqlWithAuthMode = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); + const string sqlWithConnString = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode, ConnectionString=@ConnectionString WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); @@ -366,15 +453,16 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); + sqliteCommand.Parameters.AddWithValue("@ConnectionString", (object?)EncryptConnectionString(entity.ConnectionString) ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without ConnectionString column try { - const string sqlWithEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + const string sqlWithAuthMode = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); @@ -382,20 +470,38 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column - const string sqlWithoutEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity WHERE Id = @Id"; - await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); - sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); - sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); - sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); - sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); - sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); - sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); - await sqliteCommand.ExecuteNonQueryAsync(); + // Fallback for databases without AuthenticationMode column + try + { + const string sqlWithEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); + sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); + sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); + sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); + sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); + sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + await sqliteCommand.ExecuteNonQueryAsync(); + } + catch (SqliteException) + { + // Fallback for databases without EngineType or AuthenticationMode column + const string sqlWithoutEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity WHERE Id = @Id"; + await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); + sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); + sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); + sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); + sqliteCommand.Parameters.AddWithValue("@UserId", (object?)entity.UserId ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); + await sqliteCommand.ExecuteNonQueryAsync(); + } } } } diff --git a/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs b/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs index 3f2d393..ff628a7 100644 --- a/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs +++ b/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs @@ -3,6 +3,7 @@ using LightQueryProfiler.Shared.Models; using LightQueryProfiler.Shared.Repositories.Interfaces; using Microsoft.Extensions.Logging; +using LightQueryProfiler.Shared.Services.Interfaces; using Moq; using Xunit; @@ -289,4 +290,110 @@ public async Task SaveRecentConnectionAsync_WhenValid_CallsUpsert() c.DataSource == "localhost" && c.InitialCatalog == "AdventureWorks")), Times.Once); } + + // ─── ConnectionString mode tests ───────────────────────────────────────── + + [Fact] + public async Task SaveRecentConnectionAsync_WhenConnectionStringMode_ParsesAndSavesCorrectly() + { + // Arrange + var mockRepo = new Mock(); + mockRepo.Setup(r => r.UpsertAsync(It.IsAny())).Returns(Task.CompletedTask); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + var request = new SaveRecentConnectionRequest + { + DataSource = "", + InitialCatalog = "", + AuthenticationMode = 3, + ConnectionString = "Server=myserver;Database=mydb;User Id=myuser;Password=mypass;" + }; + + // Act + await server.SaveRecentConnectionAsync(request, TestContext.Current.CancellationToken); + + // Assert + mockRepo.Verify(r => r.UpsertAsync(It.Is(c => + c.AuthenticationMode == LightQueryProfiler.Shared.Enums.AuthenticationMode.ConnectionString && + c.ConnectionString == "Server=myserver;Database=mydb;User Id=myuser;Password=mypass;" && + c.DataSource == "myserver" && + c.InitialCatalog == "mydb")), Times.Once); + } + + [Fact] + public async Task SaveRecentConnectionAsync_WhenConnectionStringModeAndMissingConnectionString_ThrowsArgumentException() + { + // Arrange + var mockRepo = new Mock(); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + var request = new SaveRecentConnectionRequest + { + DataSource = "", + InitialCatalog = "", + AuthenticationMode = 3, + ConnectionString = "" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + server.SaveRecentConnectionAsync(request, TestContext.Current.CancellationToken)); + Assert.Contains("request", exception.Message); + } + + [Fact] + public async Task GetRecentConnectionsAsync_WhenConnectionStringModeRow_ReturnsDtoWithConnectionString() + { + // Arrange + var mockRepo = new Mock(); + var storedConnection = new Connection( + id: 1, + initialCatalog: "mydb", + creationDate: DateTime.UtcNow, + dataSource: "myserver", + integratedSecurity: false, + password: null, + userId: "myuser", + engineType: null, + authenticationMode: LightQueryProfiler.Shared.Enums.AuthenticationMode.ConnectionString, + connectionString: "Server=myserver;Database=mydb;User Id=myuser;Password=mypass;"); + + mockRepo.Setup(r => r.GetAllAsync()).ReturnsAsync(new List { storedConnection }); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + + // Act + var result = await server.GetRecentConnectionsAsync( + new GetRecentConnectionsRequest(), + TestContext.Current.CancellationToken); + + // Assert + Assert.Single(result); + var dto = result[0]; + Assert.Equal(3, dto.AuthenticationMode); + Assert.Equal("Server=myserver;Database=mydb;User Id=myuser;Password=mypass;", dto.ConnectionString); + Assert.Equal("myserver", dto.DataSource); + Assert.Equal("mydb", dto.InitialCatalog); + } + + [Fact] + public async Task StartProfilingAsync_WhenEngineTypeIsZero_IsValidInput() + { + // Arrange — EngineType=0 should NOT throw; it will attempt to connect (and fail in tests, but not on validation) + var request = new StartProfilingRequest + { + SessionName = "TestSession", + EngineType = 0, + ConnectionString = "Server=localhost;Database=test;" + }; + + // Act + // EngineType=0 is now valid (auto-detect sentinel); the method will proceed past + // the validation guard and fail when it tries to open a real DB connection. + // We verify that the exception thrown is NOT an ArgumentException about EngineType. + var exception = await Record.ExceptionAsync(() => + _server.StartProfilingAsync(request, TestContext.Current.CancellationToken)); + + // Assert — should NOT throw ArgumentException for EngineType + Assert.True( + exception == null || exception is not ArgumentException, + $"Expected no ArgumentException for EngineType=0, but got: {exception?.GetType().Name}: {exception?.Message}"); + } } diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index 9bae787..ce0c7b3 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to the Light Query Profiler extension will be documented in The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-04-xx + +### Added + +- **Connection String authentication mode**: New option in the Authentication Mode dropdown. + Enter a full ADO.NET connection string directly — server, database, username/password fields + are hidden. The engine type (SQL Server vs Azure SQL) is detected automatically from the + connection string. Connection strings are encrypted before storage and decrypted on retrieval + from Recent Connections. + ## [1.2.0] - 2026-03-30 ### Added diff --git a/vscode-extension/README.md b/vscode-extension/README.md index afe418a..3158058 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -86,6 +86,7 @@ The format is compatible with events exported from the **Light Query Profiler de | Windows Authentication | Uses the current Windows user credentials (Windows only) | | SQL Server Authentication | Username and password | | Azure Active Directory | Azure AD authentication for Azure SQL Database | +| Connection String | Provide a full ADO.NET connection string. Supports any valid SQL Server or Azure SQL Database connection string. | ## Supported Platforms diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 6f7383a..32259db 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "light-query-profiler", "displayName": "Light Query Profiler", "description": "SQL Server and Azure SQL Database query profiler for VS Code", - "version": "1.2.0", + "version": "1.3.0", "publisher": "brandochn", "author": { "name": "Hildebrando Chávez", @@ -110,4 +110,4 @@ "vsce": { "baseImagesUrl": "https://github.com/brandochn/LightQueryProfiler/raw/main/vscode-extension" } -} \ No newline at end of file +} diff --git a/vscode-extension/src/models/authentication-mode.ts b/vscode-extension/src/models/authentication-mode.ts index 8e2e4ce..e625ccc 100644 --- a/vscode-extension/src/models/authentication-mode.ts +++ b/vscode-extension/src/models/authentication-mode.ts @@ -16,6 +16,12 @@ export enum AuthenticationMode { * Azure SQL Database Authentication */ AzureSqlDatabase = 2, + + /** + * Connection String mode — user provides a raw ADO.NET connection string. + * Engine type is detected automatically from the connection string. + */ + ConnectionString = 3, } /** @@ -31,6 +37,8 @@ export function getAuthenticationModeString(mode: AuthenticationMode): string { return 'SQL Server Authentication'; case AuthenticationMode.AzureSqlDatabase: return 'Azure SQL Database'; + case AuthenticationMode.ConnectionString: + return 'Connection String'; default: return 'Unknown'; } @@ -63,5 +71,9 @@ export function getAllAuthenticationModes(): ReadonlyArray = {}): ConnectionSettings { +function makeAzureSettings( + overrides: Partial = {}, +): ConnectionSettings { return { server: 'myserver.database.windows.net', database: 'MyDatabase', @@ -27,7 +29,9 @@ function makeAzureSettings(overrides: Partial = {}): Connect /** * Builds a valid SQL Server Auth settings object. */ -function makeSqlServerAuthSettings(overrides: Partial = {}): ConnectionSettings { +function makeSqlServerAuthSettings( + overrides: Partial = {}, +): ConnectionSettings { return { server: 'localhost', database: 'master', @@ -41,7 +45,9 @@ function makeSqlServerAuthSettings(overrides: Partial = {}): /** * Builds a valid Windows Auth settings object. */ -function makeWindowsAuthSettings(overrides: Partial = {}): ConnectionSettings { +function makeWindowsAuthSettings( + overrides: Partial = {}, +): ConnectionSettings { return { server: 'localhost\\SQLEXPRESS', database: 'master', @@ -56,12 +62,16 @@ suite('validateConnectionSettings', () => { // ── Server validation ─────────────────────────────────────────────────── test('returns error when server is empty string', () => { - const result = validateConnectionSettings(makeAzureSettings({ server: '' })); + const result = validateConnectionSettings( + makeAzureSettings({ server: '' }), + ); assert.strictEqual(result, 'Server is required'); }); test('returns error when server is whitespace only', () => { - const result = validateConnectionSettings(makeAzureSettings({ server: ' ' })); + const result = validateConnectionSettings( + makeAzureSettings({ server: ' ' }), + ); assert.strictEqual(result, 'Server is required'); }); @@ -70,57 +80,95 @@ suite('validateConnectionSettings', () => { test('returns error when database is empty for Azure SQL Database', () => { // Mirrors WinForms ConfigureAsync: throws InvalidOperationException when // authMode == AzureSQLDatabase and database is blank. - const result = validateConnectionSettings(makeAzureSettings({ database: '' })); + const result = validateConnectionSettings( + makeAzureSettings({ database: '' }), + ); assert.strictEqual(result, 'Database is required'); }); test('returns error when database is whitespace for Azure SQL Database', () => { - const result = validateConnectionSettings(makeAzureSettings({ database: ' ' })); + const result = validateConnectionSettings( + makeAzureSettings({ database: ' ' }), + ); assert.strictEqual(result, 'Database is required'); }); test('returns error when database is empty for SQL Server Auth', () => { - const result = validateConnectionSettings(makeSqlServerAuthSettings({ database: '' })); + const result = validateConnectionSettings( + makeSqlServerAuthSettings({ database: '' }), + ); assert.strictEqual(result, 'Database is required'); }); test('returns error when database is empty for Windows Auth', () => { - const result = validateConnectionSettings(makeWindowsAuthSettings({ database: '' })); + const result = validateConnectionSettings( + makeWindowsAuthSettings({ database: '' }), + ); assert.strictEqual(result, 'Database is required'); }); // ── Credentials validation for Azure SQL Database ─────────────────────── test('returns error when username is empty for Azure SQL Database', () => { - const result = validateConnectionSettings(makeAzureSettings({ username: '' })); - assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeAzureSettings({ username: '' }), + ); + assert.strictEqual( + result, + 'Username is required for SQL Server and Azure SQL authentication', + ); }); test('returns error when username is undefined for Azure SQL Database', () => { - const result = validateConnectionSettings(makeAzureSettings({ username: undefined })); - assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeAzureSettings({ username: undefined }), + ); + assert.strictEqual( + result, + 'Username is required for SQL Server and Azure SQL authentication', + ); }); test('returns error when password is empty for Azure SQL Database', () => { - const result = validateConnectionSettings(makeAzureSettings({ password: '' })); - assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeAzureSettings({ password: '' }), + ); + assert.strictEqual( + result, + 'Password is required for SQL Server and Azure SQL authentication', + ); }); test('returns error when password is undefined for Azure SQL Database', () => { - const result = validateConnectionSettings(makeAzureSettings({ password: undefined })); - assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeAzureSettings({ password: undefined }), + ); + assert.strictEqual( + result, + 'Password is required for SQL Server and Azure SQL authentication', + ); }); // ── Credentials validation for SQL Server Auth ────────────────────────── test('returns error when username is empty for SQL Server Auth', () => { - const result = validateConnectionSettings(makeSqlServerAuthSettings({ username: '' })); - assert.strictEqual(result, 'Username is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeSqlServerAuthSettings({ username: '' }), + ); + assert.strictEqual( + result, + 'Username is required for SQL Server and Azure SQL authentication', + ); }); test('returns error when password is empty for SQL Server Auth', () => { - const result = validateConnectionSettings(makeSqlServerAuthSettings({ password: '' })); - assert.strictEqual(result, 'Password is required for SQL Server and Azure SQL authentication'); + const result = validateConnectionSettings( + makeSqlServerAuthSettings({ password: '' }), + ); + assert.strictEqual( + result, + 'Password is required for SQL Server and Azure SQL authentication', + ); }); // ── Valid settings return undefined ───────────────────────────────────── @@ -181,33 +229,57 @@ suite('toConnectionString', () => { test('Azure SQL: includes Server and Database', () => { const cs = toConnectionString(makeAzureSettings()); - assert.ok(cs.includes('Server=myserver.database.windows.net'), `Expected Server in: ${cs}`); - assert.ok(cs.includes('Database=MyDatabase'), `Expected Database in: ${cs}`); + assert.ok( + cs.includes('Server=myserver.database.windows.net'), + `Expected Server in: ${cs}`, + ); + assert.ok( + cs.includes('Database=MyDatabase'), + `Expected Database in: ${cs}`, + ); }); test('Azure SQL: includes User Id and Password', () => { const cs = toConnectionString(makeAzureSettings()); assert.ok(cs.includes('User Id=azureuser'), `Expected User Id in: ${cs}`); - assert.ok(cs.includes('Password=Secret123!'), `Expected Password in: ${cs}`); + assert.ok( + cs.includes('Password=Secret123!'), + `Expected Password in: ${cs}`, + ); }); test('Azure SQL: does NOT include Integrated Security', () => { const cs = toConnectionString(makeAzureSettings()); - assert.ok(!cs.includes('Integrated Security'), `Unexpected Integrated Security in: ${cs}`); + assert.ok( + !cs.includes('Integrated Security'), + `Unexpected Integrated Security in: ${cs}`, + ); }); test('Azure SQL: includes required connection metadata', () => { const cs = toConnectionString(makeAzureSettings()); - assert.ok(cs.includes('Application Name=LightQueryProfiler'), `Expected Application Name in: ${cs}`); - assert.ok(cs.includes('Connect Timeout=30'), `Expected Connect Timeout in: ${cs}`); - assert.ok(cs.includes('TrustServerCertificate=true'), `Expected TrustServerCertificate in: ${cs}`); + assert.ok( + cs.includes('Application Name=LightQueryProfiler'), + `Expected Application Name in: ${cs}`, + ); + assert.ok( + cs.includes('Connect Timeout=30'), + `Expected Connect Timeout in: ${cs}`, + ); + assert.ok( + cs.includes('TrustServerCertificate=true'), + `Expected TrustServerCertificate in: ${cs}`, + ); }); // ── Windows Authentication ────────────────────────────────────────────── test('Windows Auth: includes Integrated Security=true', () => { const cs = toConnectionString(makeWindowsAuthSettings()); - assert.ok(cs.includes('Integrated Security=true'), `Expected Integrated Security in: ${cs}`); + assert.ok( + cs.includes('Integrated Security=true'), + `Expected Integrated Security in: ${cs}`, + ); }); test('Windows Auth: does NOT include User Id or Password', () => { @@ -221,12 +293,18 @@ suite('toConnectionString', () => { test('SQL Server Auth: includes User Id and Password', () => { const cs = toConnectionString(makeSqlServerAuthSettings()); assert.ok(cs.includes('User Id=sa'), `Expected User Id in: ${cs}`); - assert.ok(cs.includes('Password=Password1!'), `Expected Password in: ${cs}`); + assert.ok( + cs.includes('Password=Password1!'), + `Expected Password in: ${cs}`, + ); }); test('SQL Server Auth: does NOT include Integrated Security', () => { const cs = toConnectionString(makeSqlServerAuthSettings()); - assert.ok(!cs.includes('Integrated Security'), `Unexpected Integrated Security in: ${cs}`); + assert.ok( + !cs.includes('Integrated Security'), + `Unexpected Integrated Security in: ${cs}`, + ); }); // ── Connection string format ──────────────────────────────────────────── @@ -237,12 +315,126 @@ suite('toConnectionString', () => { }); test('omits User Id when username is undefined', () => { - const cs = toConnectionString(makeWindowsAuthSettings({ username: undefined })); + const cs = toConnectionString( + makeWindowsAuthSettings({ username: undefined }), + ); assert.ok(!cs.includes('User Id'), `Unexpected User Id in: ${cs}`); }); test('omits Password when password is undefined', () => { - const cs = toConnectionString(makeWindowsAuthSettings({ password: undefined })); + const cs = toConnectionString( + makeWindowsAuthSettings({ password: undefined }), + ); assert.ok(!cs.includes('Password='), `Unexpected Password in: ${cs}`); }); }); + +// ── validateConnectionSettings — ConnectionString mode ──────────────────────── + +suite('validateConnectionSettings — ConnectionString mode', () => { + function makeConnStringSettings( + overrides: Partial = {}, + ): ConnectionSettings { + return { + server: '', + database: '', + authenticationMode: AuthenticationMode.ConnectionString, + connectionString: + 'Server=myserver;Database=mydb;User Id=myuser;Password=mypass;', + ...overrides, + }; + } + + test('returns undefined for valid non-empty connection string', () => { + const result = validateConnectionSettings(makeConnStringSettings()); + assert.strictEqual(result, undefined); + }); + + test('returns error when connectionString is empty', () => { + const result = validateConnectionSettings( + makeConnStringSettings({ connectionString: '' }), + ); + assert.strictEqual(result, 'Connection String is required'); + }); + + test('returns error when connectionString is whitespace only', () => { + const result = validateConnectionSettings( + makeConnStringSettings({ connectionString: ' ' }), + ); + assert.strictEqual(result, 'Connection String is required'); + }); + + test('returns error when connectionString is undefined', () => { + const result = validateConnectionSettings( + makeConnStringSettings({ connectionString: undefined }), + ); + assert.strictEqual(result, 'Connection String is required'); + }); + + test('ignores server field in ConnectionString mode', () => { + // server is empty — should NOT trigger 'Server is required' in this mode + const result = validateConnectionSettings( + makeConnStringSettings({ server: '' }), + ); + assert.strictEqual(result, undefined); + }); + + test('ignores database field in ConnectionString mode', () => { + // database is empty — should NOT trigger 'Database is required' in this mode + const result = validateConnectionSettings( + makeConnStringSettings({ database: '' }), + ); + assert.strictEqual(result, undefined); + }); +}); + +// ── toConnectionString — ConnectionString mode ──────────────────────────────── + +suite('toConnectionString — ConnectionString mode', () => { + test('returns the raw connection string unchanged', () => { + const raw = 'Server=myserver;Database=mydb;User Id=myuser;Password=mypass;'; + const settings: ConnectionSettings = { + server: '', + database: '', + authenticationMode: AuthenticationMode.ConnectionString, + connectionString: raw, + }; + const result = toConnectionString(settings); + assert.strictEqual(result, raw); + }); + + test('returns empty string when connectionString is undefined', () => { + const settings: ConnectionSettings = { + server: '', + database: '', + authenticationMode: AuthenticationMode.ConnectionString, + connectionString: undefined, + }; + const result = toConnectionString(settings); + assert.strictEqual(result, ''); + }); + + test('does NOT append Application Name or other metadata', () => { + const raw = 'Server=myserver;Database=mydb;'; + const settings: ConnectionSettings = { + server: '', + database: '', + authenticationMode: AuthenticationMode.ConnectionString, + connectionString: raw, + }; + const result = toConnectionString(settings); + assert.ok( + !result.includes('Application Name'), + `Should not include Application Name in: ${result}`, + ); + }); +}); + +// ── getEngineType — ConnectionString mode ───────────────────────────────────── + +suite('getEngineType — ConnectionString mode', () => { + test('returns 0 for ConnectionString authentication mode (auto-detect sentinel)', () => { + const result = getEngineType(AuthenticationMode.ConnectionString); + assert.strictEqual(result, 0); + }); +}); diff --git a/vscode-extension/src/views/profiler-panel-provider.ts b/vscode-extension/src/views/profiler-panel-provider.ts index 4967dd7..89b84f0 100644 --- a/vscode-extension/src/views/profiler-panel-provider.ts +++ b/vscode-extension/src/views/profiler-panel-provider.ts @@ -22,6 +22,7 @@ interface ConnectionSettings { authenticationMode: AuthenticationMode; username?: string; password?: string; + connectionString?: string; } /** @@ -434,12 +435,16 @@ export class ProfilerPanelProvider { settings.authenticationMode === AuthenticationMode.WindowsAuth, authenticationMode: settings.authenticationMode, engineType: undefined, + connectionString: settings.connectionString, // ← NEW }); this.log("Recent connection saved"); } catch (saveError) { const saveMessage = saveError instanceof Error ? saveError.message : String(saveError); this.logError(`Failed to save recent connection: ${saveMessage}`); + } finally { + // Clear sensitive data: connection strings can contain embedded passwords. + this.currentConnectionSettings = undefined; } } @@ -1202,6 +1207,33 @@ export class ProfilerPanelProvider { box-shadow: 0 0 0 1px var(--vscode-focusBorder); } + textarea { + width: 100%; + padding: 5px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 3px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + line-height: 1.5; + min-height: 64px; + resize: vertical; + transition: border-color 0.15s; + } + + textarea:focus { + outline: none; + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); + } + + textarea:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; + } + /* ── Toolbar ────────────────────────────────────────────────────── */ .toolbar { display: flex; @@ -1835,7 +1867,7 @@ export class ProfilerPanelProvider { -
+
@@ -1854,6 +1886,18 @@ export class ProfilerPanelProvider {
+ @@ -2048,6 +2092,9 @@ export class ProfilerPanelProvider { const usernameGroup = document.getElementById('usernameGroup'); const passwordGroup = document.getElementById('passwordGroup'); const databaseGroup = document.getElementById('databaseGroup'); + const serverGroup = document.getElementById('serverGroup'); + const connectionStringInput = document.getElementById('connectionStringInput'); + const connectionStringGroup = document.getElementById('connectionStringGroup'); const startBtn = document.getElementById('startBtn'); const startIcon = document.getElementById('startIcon'); @@ -2152,14 +2199,24 @@ export class ProfilerPanelProvider { } function updateAuthVisibility() { - const mode = parseInt(authMode.value); + const mode = parseInt(authMode.value, 10); const isWindows = mode === 0; const needsCreds = mode === 1 || mode === 2; + const isConnString = mode === 3; - databaseGroup.classList.toggle('hidden', isWindows); - if (isWindows) { databaseInput.value = ''; } + serverGroup.classList.toggle('hidden', isConnString); + databaseGroup.classList.toggle('hidden', isWindows || isConnString); usernameGroup.classList.toggle('hidden', !needsCreds); passwordGroup.classList.toggle('hidden', !needsCreds); + connectionStringGroup.classList.toggle('hidden', !isConnString); + + connectionStringInput.setAttribute( + 'aria-required', + isConnString ? 'true' : 'false' + ); + + if (isWindows) { databaseInput.value = ''; } + if (!isConnString) { connectionStringInput.value = ''; } } authMode.addEventListener('change', updateAuthVisibility); @@ -2186,12 +2243,34 @@ export class ProfilerPanelProvider { startBtn.addEventListener('click', () => { if (isStarting) { return; } - const mode = parseInt(authMode.value); + const mode = parseInt(authMode.value, 10); const serverVal = serverInput.value.trim(); const databaseVal = databaseInput.value.trim(); const usernameVal = usernameInput.value.trim(); const passwordVal = passwordInput.value; + // ── Connection String mode (mode === 3) ────────────────────────────── + if (mode === 3) { + const csVal = connectionStringInput.value.trim(); + if (!csVal) { + showError('Connection String is required'); + return; + } + const settings = { + server: '', + database: '', + authenticationMode: mode, + connectionString: csVal, + }; + // ⚠ Security: do NOT include connectionString in vscode.setState. + // Connection strings may contain passwords — consistent with the existing + // behaviour that excludes the 'password' field from state. + vscode.setState({ authenticationMode: mode }); + setStarting(true); + vscode.postMessage({ command: 'start', data: settings }); + return; + } + // ── Client-side validation ─────────────────────────────────── // Mirrors WinForms ConfigureAsync validation logic: // - Server is always required @@ -2503,17 +2582,21 @@ export class ProfilerPanelProvider { databaseInput.disabled = !enabled; usernameInput.disabled = !enabled; passwordInput.disabled = !enabled; + connectionStringInput.disabled = !enabled; break; } case 'setConnectionFields': { const conn = /** @type {*} */ (msg.data); - // Set auth mode first so the change event fires before other fields authMode.value = String(conn.authenticationMode ?? 0); authMode.dispatchEvent(new Event('change')); - serverInput.value = conn.dataSource ?? ''; - databaseInput.value = conn.initialCatalog ?? ''; - usernameInput.value = conn.userId ?? ''; - passwordInput.value = conn.password ?? ''; + if (conn.authenticationMode === 3) { + connectionStringInput.value = conn.connectionString ?? ''; + } else { + serverInput.value = conn.dataSource ?? ''; + databaseInput.value = conn.initialCatalog ?? ''; + usernameInput.value = conn.userId ?? ''; + passwordInput.value = conn.password ?? ''; + } break; } } diff --git a/vscode-extension/src/views/recent-connections-panel-provider.ts b/vscode-extension/src/views/recent-connections-panel-provider.ts index 1f51cb9..5afeef6 100644 --- a/vscode-extension/src/views/recent-connections-panel-provider.ts +++ b/vscode-extension/src/views/recent-connections-panel-provider.ts @@ -287,6 +287,7 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { switch (authenticationMode) { case 1: return 'SQL Server'; case 2: return 'Azure AD'; + case 3: return 'Conn. String'; default: return 'Windows'; } } From 1041fd049d155c1302c7feb18675f7d4ef459e5f Mon Sep 17 00:00:00 2001 From: Hildebrando Chavez Date: Fri, 3 Apr 2026 19:43:08 -0600 Subject: [PATCH 2/2] Set ApplicationName for profiler DB connections Normalize connection strings in server when using ConnectionString mode by setting ApplicationName='LightQueryProfiler' before creating ApplicationDbContext. This tags profiler-created SQL connections via client_app_name so ProfilerService.IsProfilerGeneratedEvent can exclude profiler-generated XEvent operations --- src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs index 16e0bec..136c63b 100644 --- a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs +++ b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs @@ -90,7 +90,15 @@ public async Task StartProfilingAsync(StartProfilingRequest request, Cancellatio try { - var dbContext = new ApplicationDbContext(request.ConnectionString); + // Guarantee the profiler's own SQL connections are tagged with "LightQueryProfiler" + // as the Application Name. ProfilerService.IsProfilerGeneratedEvent uses + // client_app_name to exclude the profiler's XEvent management queries (create session, + // read ring buffer, etc.) from the captured event stream. In standard auth modes this + // is handled by toConnectionString() on the TypeScript client side; for ConnectionString + // mode we normalise here at the server level so all modes are covered consistently. + var csBuilder = new SqlConnectionStringBuilder(request.ConnectionString); + csBuilder.ApplicationName = "LightQueryProfiler"; + var dbContext = new ApplicationDbContext(csBuilder.ConnectionString); DatabaseEngineType effectiveEngineType; if (request.EngineType == 0)