diff --git a/.github/workflows/dotnet-efcore-tests.yaml b/.github/workflows/dotnet-efcore-tests.yaml new file mode 100644 index 00000000..4d8e04da --- /dev/null +++ b/.github/workflows/dotnet-efcore-tests.yaml @@ -0,0 +1,35 @@ +name: .NET EF Core Tests + +on: + push: + branches: [master] + paths: + - 'dotnet/**' + pull_request: + branches: [master] + paths: + - 'dotnet/**' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + working-directory: dotnet/sqlcommenter-efcore + + - name: Build + run: dotnet build --no-restore + working-directory: dotnet/sqlcommenter-efcore + + - name: Test + run: dotnet test --no-build --verbosity normal + working-directory: dotnet/sqlcommenter-efcore diff --git a/.gitignore b/.gitignore index c792cffb..7422d0d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ php/sqlcommenter-php/packages/sqlcommenter-laravel/vendor/* .idea/** .DS_Store + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ +*.nupkg +*.snupkg diff --git a/README.md b/README.md index 8cf47454..8715ad52 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,5 @@ Contains all the various `sqlcommenter-*` implementations. - [X] [Drizzle](nodejs/sqlcommenter-nodejs/packages/sqlcommenter-drizzle/README.md) - [X] Php - [X] [Laravel](php/sqlcommenter-php/packages/sqlcommenter-laravel) +- [X] .NET + - [X] [EF Core](dotnet/sqlcommenter-efcore/README.md) diff --git a/dotnet/sqlcommenter-efcore/.editorconfig b/dotnet/sqlcommenter-efcore/.editorconfig new file mode 100644 index 00000000..95cae9a5 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{csproj,props,targets}] +indent_size = 2 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/dotnet/sqlcommenter-efcore/.gitignore b/dotnet/sqlcommenter-efcore/.gitignore new file mode 100644 index 00000000..85dc857b --- /dev/null +++ b/dotnet/sqlcommenter-efcore/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +*.nupkg +*.snupkg diff --git a/dotnet/sqlcommenter-efcore/LICENSE b/dotnet/sqlcommenter-efcore/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/dotnet/sqlcommenter-efcore/README.md b/dotnet/sqlcommenter-efcore/README.md new file mode 100644 index 00000000..d47b073c --- /dev/null +++ b/dotnet/sqlcommenter-efcore/README.md @@ -0,0 +1,69 @@ +# QueryDoctor.SqlCommenter.EFCore + +EF Core interceptor that appends [SQLCommenter](https://google.github.io/sqlcommenter/)-formatted comments to SQL queries for query attribution and observability. + +## Installation + +```bash +dotnet add package QueryDoctor.SqlCommenter.EFCore +``` + +## Quick Start + +```csharp +// In your DbContext configuration or Program.cs +options.UseSqlServer(connectionString).AddSqlCommenter(); +``` + +This will automatically append comments to all SQL queries: + +```sql +SELECT * FROM "Users" WHERE "Id" = @p0 /*action='GetUser',controller='Users',db_driver='efcore',file='Controllers%2FUsersController.cs%3A42%3A1',framework='efcore%3A8.0',method='GET'*/ +``` + +## Tags + +| Tag | Description | Example | +|-----|-------------|---------| +| `action` | Controller action or method name | `GetUser` | +| `controller` | Controller name (without suffix) | `Users` | +| `db_driver` | Database driver identifier | `efcore` | +| `file` | Source file location (path:line:column) | `Controllers/UsersController.cs:42:1` | +| `framework` | EF Core version | `efcore:8.0` | +| `method` | HTTP method | `GET` | + +## Configuration + +```csharp +options.UseSqlServer(connectionString).AddSqlCommenter(o => +{ + o.Enabled = true; // Enable/disable (default: true) + o.EnableStackInspection = true; // Auto-detect caller info (default: true) + o.MaxStackDepth = 30; // Max stack frames to inspect (default: 30) + o.IncludeFrameworkVersion = true; // Include EF Core version (default: true) +}); +``` + +## Explicit Context + +For precise control, use `QueryTaggingContext.SetContext()` instead of stack inspection: + +```csharp +using (QueryTaggingContext.SetContext(action: "GetUser", controller: "Users")) +{ + var user = await context.Users.FindAsync(id); +} +``` + +Explicit context always takes priority over stack inspection. Caller file path, line number, and member name are automatically captured via `[CallerFilePath]`, `[CallerLineNumber]`, and `[CallerMemberName]`. + +## Spec Compliance + +- Keys are sorted lexicographically +- Values are URL-encoded and wrapped in single quotes +- SQL with existing comments (`/*` or `*/`) is not modified +- Errors during tagging never fail the query + +## License + +Apache-2.0 diff --git a/dotnet/sqlcommenter-efcore/sqlcommenter-efcore.sln b/dotnet/sqlcommenter-efcore/sqlcommenter-efcore.sln new file mode 100644 index 00000000..fe132516 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/sqlcommenter-efcore.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C1494A41-1586-4710-86EB-17134121B934}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryDoctor.SqlCommenter.EFCore", "src\QueryDoctor.SqlCommenter.EFCore\QueryDoctor.SqlCommenter.EFCore.csproj", "{7FD1588E-7B09-4401-9FA0-61E6D6BC1018}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{78DB0588-93C0-4BB5-8810-A5DA2D88D0FA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryDoctor.SqlCommenter.EFCore.Tests", "test\QueryDoctor.SqlCommenter.EFCore.Tests\QueryDoctor.SqlCommenter.EFCore.Tests.csproj", "{B42680B7-4FBF-4B32-A66D-9781014C232D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7FD1588E-7B09-4401-9FA0-61E6D6BC1018}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FD1588E-7B09-4401-9FA0-61E6D6BC1018}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FD1588E-7B09-4401-9FA0-61E6D6BC1018}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FD1588E-7B09-4401-9FA0-61E6D6BC1018}.Release|Any CPU.Build.0 = Release|Any CPU + {B42680B7-4FBF-4B32-A66D-9781014C232D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B42680B7-4FBF-4B32-A66D-9781014C232D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B42680B7-4FBF-4B32-A66D-9781014C232D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B42680B7-4FBF-4B32-A66D-9781014C232D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7FD1588E-7B09-4401-9FA0-61E6D6BC1018} = {C1494A41-1586-4710-86EB-17134121B934} + {B42680B7-4FBF-4B32-A66D-9781014C232D} = {78DB0588-93C0-4BB5-8810-A5DA2D88D0FA} + EndGlobalSection +EndGlobal diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/DbContextOptionsBuilderExtensions.cs b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/DbContextOptionsBuilderExtensions.cs new file mode 100644 index 00000000..c5315ffe --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/DbContextOptionsBuilderExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +namespace QueryDoctor.SqlCommenter.EFCore; + +/// +/// Extension methods for to register the SQLCommenter interceptor. +/// +public static class DbContextOptionsBuilderExtensions +{ + /// + /// Adds the SQLCommenter interceptor to the DbContext options. + /// + /// The DbContext options builder. + /// Optional delegate to configure . + /// The same for chaining. + public static DbContextOptionsBuilder AddSqlCommenter( + this DbContextOptionsBuilder optionsBuilder, + Action? configureOptions = null) + { + var options = new SqlCommenterOptions(); + configureOptions?.Invoke(options); + optionsBuilder.AddInterceptors(new SqlCommenterInterceptor(options)); + return optionsBuilder; + } +} diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryDoctor.SqlCommenter.EFCore.csproj b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryDoctor.SqlCommenter.EFCore.csproj new file mode 100644 index 00000000..ac3b319d --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryDoctor.SqlCommenter.EFCore.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + QueryDoctor.SqlCommenter.EFCore + + + QueryDoctor.SqlCommenter.EFCore + 8.0.0 + Query Doctor + Query Doctor + EF Core interceptor that appends SQLCommenter-formatted comments to SQL queries for query attribution and observability. + sqlcommenter;efcore;entity-framework-core;sql;observability + Apache-2.0 + https://github.com/query-doctor/sqlcommenter + https://github.com/query-doctor/sqlcommenter.git + git + README.md + true + + + true + true + true + snupkg + + + + + + + + + + + + diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTagInfo.cs b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTagInfo.cs new file mode 100644 index 00000000..47191a06 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTagInfo.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; + +namespace QueryDoctor.SqlCommenter.EFCore; + +/// +/// Holds information about the query origin for SQLCommenter tagging. +/// +public sealed class QueryTagInfo +{ + /// Source file path where the query originated. + public string? FilePath { get; init; } + + /// Line number in the source file. + public int LineNumber { get; init; } + + /// Column number in the source file. Defaults to 1 if not available. + public int ColumnNumber { get; init; } + + /// Name of the method that initiated the query. + public string? MemberName { get; init; } + + /// Controller action name (for ASP.NET Core controllers). + public string? Action { get; init; } + + /// Controller name without the "Controller" suffix. + public string? Controller { get; init; } + + /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). + public string? HttpMethod { get; init; } + + /// + /// Formats the tag info as a SQLCommenter-compatible comment. + /// Keys are sorted lexicographically per the spec. + /// + /// Whether to include the EF Core framework version tag. + /// A SQL comment string, or empty string if no tags are available. + public string ToSqlComment(bool includeFrameworkVersion = true) + { + var pairs = new SortedDictionary(StringComparer.Ordinal); + + if (!string.IsNullOrEmpty(Action)) + { + pairs["action"] = Action; + } + + if (!string.IsNullOrEmpty(Controller)) + { + pairs["controller"] = Controller; + } + + pairs["db_driver"] = "efcore"; + + if (!string.IsNullOrEmpty(FilePath)) + { + var normalizedPath = NormalizePath(FilePath); + var column = ColumnNumber > 0 ? ColumnNumber : 1; + var location = LineNumber > 0 + ? $"{normalizedPath}:{LineNumber}:{column}" + : normalizedPath; + pairs["file"] = location; + } + + if (includeFrameworkVersion) + { + var efCoreVersion = typeof(DbContext).Assembly.GetName().Version; + var major = efCoreVersion?.Major ?? 8; + var minor = efCoreVersion?.Minor ?? 0; + pairs["framework"] = $"efcore:{major}.{minor}"; + } + + if (!string.IsNullOrEmpty(HttpMethod)) + { + pairs["method"] = HttpMethod; + } + + if (pairs.Count == 0) + return string.Empty; + + var formattedPairs = pairs.Select(kvp => $"{EncodeKey(kvp.Key)}={EncodeValue(kvp.Value)}"); + return $"/*{string.Join(",", formattedPairs)}*/"; + } + + private static string NormalizePath(string path) + { + var srcIndex = path.IndexOf("src/", StringComparison.OrdinalIgnoreCase); + if (srcIndex == -1) + { + srcIndex = path.IndexOf("src\\", StringComparison.OrdinalIgnoreCase); + } + + if (srcIndex >= 0) + { + return path[(srcIndex + 4)..].Replace('\\', '/'); + } + + return Path.GetFileName(path); + } + + private static string EncodeKey(string key) + { + var encoded = Uri.EscapeDataString(key); + return EscapeMetaCharacters(encoded); + } + + private static string EncodeValue(string value) + { + var urlEncoded = Uri.EscapeDataString(value); + var escaped = urlEncoded.Replace("'", "\\'"); + return $"'{escaped}'"; + } + + private static string EscapeMetaCharacters(string value) + { + return value.Replace("\\", "\\\\").Replace("'", "\\'"); + } +} diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTaggingContext.cs b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTaggingContext.cs new file mode 100644 index 00000000..906b20c3 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/QueryTaggingContext.cs @@ -0,0 +1,68 @@ +using System.Runtime.CompilerServices; + +namespace QueryDoctor.SqlCommenter.EFCore; + +/// +/// Provides ambient context for SQL query tagging following SQLCommenter format. +/// Uses to flow context across async/await boundaries. +/// +public static class QueryTaggingContext +{ + private static readonly AsyncLocal _currentTag = new(); + + /// + /// Gets or sets the current query tag info for the async context. + /// Returns null if no context has been set. + /// + public static QueryTagInfo? Current + { + get => _currentTag.Value; + set => _currentTag.Value = value; + } + + /// + /// Sets the query context with caller information. + /// Caller attributes are automatically captured by the compiler. + /// + /// The action name (e.g., controller action). + /// The controller name. + /// Automatically captured source file path. + /// Automatically captured source line number. + /// Automatically captured calling member name. + /// An that restores the previous context when disposed. + public static IDisposable SetContext( + string? action = null, + string? controller = null, + [CallerFilePath] string? filePath = null, + [CallerLineNumber] int lineNumber = 0, + [CallerMemberName] string? memberName = null) + { + var previous = _currentTag.Value; + _currentTag.Value = new QueryTagInfo + { + FilePath = filePath, + LineNumber = lineNumber, + MemberName = memberName, + Action = action, + Controller = controller + }; + return new ContextScope(previous); + } + + private sealed class ContextScope : IDisposable + { + private readonly QueryTagInfo? _previous; + private bool _disposed; + + public ContextScope(QueryTagInfo? previous) => _previous = previous; + + public void Dispose() + { + if (!_disposed) + { + _currentTag.Value = _previous; + _disposed = true; + } + } + } +} diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterInterceptor.cs b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterInterceptor.cs new file mode 100644 index 00000000..3d71a65d --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterInterceptor.cs @@ -0,0 +1,217 @@ +using System.Data.Common; +using System.Diagnostics; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace QueryDoctor.SqlCommenter.EFCore; + +/// +/// EF Core command interceptor that appends SQLCommenter-formatted comments to queries. +/// Comments include source file location and method name for debugging and profiling. +/// +public class SqlCommenterInterceptor : DbCommandInterceptor +{ + private readonly SqlCommenterOptions _options; + + private static readonly HashSet _frameworkAssemblyPrefixes = + [ + "Microsoft.EntityFrameworkCore", + "Npgsql", + "System.", + "Microsoft.Extensions", + "Microsoft.AspNetCore" + ]; + + /// + /// Initializes a new instance of . + /// + /// Configuration options. If null, default options are used. + public SqlCommenterInterceptor(SqlCommenterOptions? options = null) + { + _options = options ?? new SqlCommenterOptions(); + } + + /// + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + AppendSqlComment(command); + return base.ReaderExecuting(command, eventData, result); + } + + /// + public override ValueTask> ReaderExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + AppendSqlComment(command); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + /// + public override InterceptionResult NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + AppendSqlComment(command); + return base.NonQueryExecuting(command, eventData, result); + } + + /// + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + AppendSqlComment(command); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + + /// + public override InterceptionResult ScalarExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + AppendSqlComment(command); + return base.ScalarExecuting(command, eventData, result); + } + + /// + public override ValueTask> ScalarExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + AppendSqlComment(command); + return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); + } + + private void AppendSqlComment(DbCommand command) + { + if (!_options.Enabled) + return; + + if (command.CommandText.Contains("/*") || command.CommandText.Contains("*/")) + return; + + try + { + var tagInfo = GetTagInfo(); + if (tagInfo != null) + { + var comment = tagInfo.ToSqlComment(_options.IncludeFrameworkVersion); + if (!string.IsNullOrEmpty(comment)) + { + command.CommandText = $"{command.CommandText} {comment}"; + } + } + } + catch + { + // Never fail a query due to tagging errors + } + } + + private QueryTagInfo? GetTagInfo() + { + var explicitContext = QueryTaggingContext.Current; + if (explicitContext != null) + return explicitContext; + + if (_options.EnableStackInspection) + return InspectStackForCallerInfo(); + + return null; + } + + private QueryTagInfo? InspectStackForCallerInfo() + { + try + { + var stackTrace = new StackTrace(fNeedFileInfo: true); + var frames = stackTrace.GetFrames(); + + if (frames == null) + return null; + + for (int i = 0; i < Math.Min(frames.Length, _options.MaxStackDepth); i++) + { + var frame = frames[i]; + var method = frame.GetMethod(); + if (method == null) continue; + + var declaringType = method.DeclaringType; + if (declaringType == null) continue; + + var assemblyName = declaringType.Assembly.GetName().Name; + if (assemblyName == null) continue; + + if (IsFrameworkAssembly(assemblyName)) continue; + if (declaringType.Namespace?.StartsWith("QueryDoctor.SqlCommenter.EFCore") == true) continue; + + var fileName = frame.GetFileName(); + var lineNumber = frame.GetFileLineNumber(); + var columnNumber = frame.GetFileColumnNumber(); + + return new QueryTagInfo + { + FilePath = fileName, + LineNumber = lineNumber, + ColumnNumber = columnNumber > 0 ? columnNumber : 1, + MemberName = method.Name, + Controller = GetControllerName(declaringType), + Action = GetActionName(declaringType, method), + HttpMethod = GetHttpMethod(method) + }; + } + } + catch + { + // Stack inspection can fail in various scenarios + } + + return null; + } + + private static bool IsFrameworkAssembly(string assemblyName) + { + foreach (var prefix in _frameworkAssemblyPrefixes) + { + if (assemblyName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string? GetControllerName(Type type) + { + var name = type.Name; + if (name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)) + return name[..^"Controller".Length]; + return null; + } + + private static string? GetActionName(Type declaringType, System.Reflection.MethodBase method) + { + if (!declaringType.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)) + return null; + return method.Name; + } + + private static string? GetHttpMethod(System.Reflection.MethodBase method) + { + var attrs = method.GetCustomAttributes(false); + foreach (var attr in attrs) + { + var attrName = attr.GetType().Name; + var httpMethod = attrName switch + { + "HttpGetAttribute" => "GET", + "HttpPostAttribute" => "POST", + "HttpPutAttribute" => "PUT", + "HttpDeleteAttribute" => "DELETE", + "HttpPatchAttribute" => "PATCH", + "HttpHeadAttribute" => "HEAD", + "HttpOptionsAttribute" => "OPTIONS", + _ => null + }; + if (httpMethod != null) return httpMethod; + } + return null; + } +} diff --git a/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterOptions.cs b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterOptions.cs new file mode 100644 index 00000000..eb84384f --- /dev/null +++ b/dotnet/sqlcommenter-efcore/src/QueryDoctor.SqlCommenter.EFCore/SqlCommenterOptions.cs @@ -0,0 +1,30 @@ +namespace QueryDoctor.SqlCommenter.EFCore; + +/// +/// Configuration options for the SQLCommenter interceptor. +/// +public class SqlCommenterOptions +{ + /// + /// Whether SQLCommenter tagging is enabled. Default: true. + /// + public bool Enabled { get; set; } = true; + + /// + /// Whether to inspect the call stack for caller information + /// when no explicit context is set. Default: true. + /// + public bool EnableStackInspection { get; set; } = true; + + /// + /// Maximum stack depth to inspect. Higher values provide + /// better accuracy but increase overhead. Default: 30. + /// + public int MaxStackDepth { get; set; } = 30; + + /// + /// Whether to include the EF Core framework version in comments. + /// Default: true. + /// + public bool IncludeFrameworkVersion { get; set; } = true; +} diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/DbContextOptionsBuilderExtensionsTests.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/DbContextOptionsBuilderExtensionsTests.cs new file mode 100644 index 00000000..19073b42 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/DbContextOptionsBuilderExtensionsTests.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace QueryDoctor.SqlCommenter.EFCore.Tests; + +public class DbContextOptionsBuilderExtensionsTests +{ + [Fact] + public void AddSqlCommenter_RegistersInterceptor() + { + var optionsBuilder = new DbContextOptionsBuilder().UseInMemoryDatabase("test_register"); + optionsBuilder.AddSqlCommenter(); + var options = optionsBuilder.Options; + var extension = options.FindExtension(); + Assert.NotNull(extension); + Assert.NotNull(extension.Interceptors); + Assert.Contains(extension.Interceptors, i => i is SqlCommenterInterceptor); + } + + [Fact] + public void AddSqlCommenter_CustomOptions_InvokesDelegate() + { + var optionsBuilder = new DbContextOptionsBuilder().UseInMemoryDatabase("test_custom"); + var delegateInvoked = false; + optionsBuilder.AddSqlCommenter(o => { delegateInvoked = true; o.Enabled = false; }); + Assert.True(delegateInvoked); + } +} diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/GlobalUsings.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryDoctor.SqlCommenter.EFCore.Tests.csproj b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryDoctor.SqlCommenter.EFCore.Tests.csproj new file mode 100644 index 00000000..726215a4 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryDoctor.SqlCommenter.EFCore.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTagInfoTests.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTagInfoTests.cs new file mode 100644 index 00000000..298174b0 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTagInfoTests.cs @@ -0,0 +1,118 @@ +namespace QueryDoctor.SqlCommenter.EFCore.Tests; + +public class QueryTagInfoTests +{ + [Fact] + public void ToSqlComment_WithAllFields_ProducesCorrectFormat() + { + var info = new QueryTagInfo + { + Action = "GetItems", Controller = "Items", + FilePath = "/app/src/Controllers/ItemsController.cs", + LineNumber = 42, ColumnNumber = 5, HttpMethod = "GET" + }; + var comment = info.ToSqlComment(); + Assert.StartsWith("/*", comment); + Assert.EndsWith("*/", comment); + Assert.Contains("action='GetItems'", comment); + Assert.Contains("controller='Items'", comment); + Assert.Contains("db_driver='efcore'", comment); + Assert.Contains("method='GET'", comment); + } + + [Fact] + public void ToSqlComment_MinimalFields_ProducesOnlyDbDriver() + { + var info = new QueryTagInfo(); + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Equal("/*db_driver='efcore'*/", comment); + } + + [Fact] + public void ToSqlComment_DbDriverIsEfcore() + { + var info = new QueryTagInfo(); + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("db_driver='efcore'", comment); + } + + [Fact] + public void ToSqlComment_KeysSortedLexicographically() + { + var info = new QueryTagInfo { Action = "Get", Controller = "Test", HttpMethod = "GET" }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + var actionIdx = comment.IndexOf("action="); + var controllerIdx = comment.IndexOf("controller="); + var dbDriverIdx = comment.IndexOf("db_driver="); + var methodIdx = comment.IndexOf("method="); + Assert.True(actionIdx < controllerIdx); + Assert.True(controllerIdx < dbDriverIdx); + Assert.True(dbDriverIdx < methodIdx); + } + + [Fact] + public void ToSqlComment_ValuesAreUrlEncoded() + { + var info = new QueryTagInfo { FilePath = "/app/src/path with spaces/file.cs", LineNumber = 1 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("%20", comment); + Assert.DoesNotContain("path with spaces", comment); + } + + [Fact] + public void ToSqlComment_FilePathNormalization_StripsSrcPrefix() + { + var info = new QueryTagInfo { FilePath = "/home/user/project/src/Controllers/TestController.cs", LineNumber = 10, ColumnNumber = 1 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("Controllers%2FTestController.cs", comment); + Assert.DoesNotContain("/home/user/project", comment); + } + + [Fact] + public void ToSqlComment_FilePathNormalization_WindowsPaths() + { + var info = new QueryTagInfo { FilePath = "C:\\Users\\dev\\project\\src\\Controllers\\TestController.cs", LineNumber = 10, ColumnNumber = 1 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("Controllers%2FTestController.cs", comment); + } + + [Fact] + public void ToSqlComment_FilePathNormalization_NoSrcFolder_UsesFilename() + { + var info = new QueryTagInfo { FilePath = "/home/user/project/Controllers/TestController.cs", LineNumber = 10, ColumnNumber = 1 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("TestController.cs", comment); + } + + [Fact] + public void ToSqlComment_FileLocation_IncludesLineAndColumn() + { + var info = new QueryTagInfo { FilePath = "/app/src/Test.cs", LineNumber = 42, ColumnNumber = 7 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("Test.cs%3A42%3A7", comment); + } + + [Fact] + public void ToSqlComment_DefaultColumnIsOne() + { + var info = new QueryTagInfo { FilePath = "/app/src/Test.cs", LineNumber = 42, ColumnNumber = 0 }; + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.Contains("Test.cs%3A42%3A1", comment); + } + + [Fact] + public void ToSqlComment_WithFrameworkVersion_IncludesFrameworkTag() + { + var info = new QueryTagInfo(); + var comment = info.ToSqlComment(includeFrameworkVersion: true); + Assert.Contains("framework='efcore%3A", comment); + } + + [Fact] + public void ToSqlComment_WithoutFrameworkVersion_OmitsFrameworkTag() + { + var info = new QueryTagInfo(); + var comment = info.ToSqlComment(includeFrameworkVersion: false); + Assert.DoesNotContain("framework=", comment); + } +} diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTaggingContextTests.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTaggingContextTests.cs new file mode 100644 index 00000000..b479b385 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/QueryTaggingContextTests.cs @@ -0,0 +1,75 @@ +namespace QueryDoctor.SqlCommenter.EFCore.Tests; + +public class QueryTaggingContextTests +{ + [Fact] + public void Current_DefaultsToNull() + { + QueryTaggingContext.Current = null; + Assert.Null(QueryTaggingContext.Current); + } + + [Fact] + public void SetContext_SetsCurrentValue() + { + using var scope = QueryTaggingContext.SetContext(action: "Test"); + Assert.NotNull(QueryTaggingContext.Current); + Assert.Equal("Test", QueryTaggingContext.Current!.Action); + } + + [Fact] + public void SetContext_Dispose_RestoresPrevious() + { + QueryTaggingContext.Current = null; + using (QueryTaggingContext.SetContext(action: "Test")) + { Assert.NotNull(QueryTaggingContext.Current); } + Assert.Null(QueryTaggingContext.Current); + } + + [Fact] + public void SetContext_NestedScopes_RestoresCorrectly() + { + QueryTaggingContext.Current = null; + using (QueryTaggingContext.SetContext(action: "Outer")) + { + Assert.Equal("Outer", QueryTaggingContext.Current!.Action); + using (QueryTaggingContext.SetContext(action: "Inner")) + { Assert.Equal("Inner", QueryTaggingContext.Current!.Action); } + Assert.Equal("Outer", QueryTaggingContext.Current!.Action); + } + Assert.Null(QueryTaggingContext.Current); + } + + [Fact] + public void SetContext_CapturesCallerFilePath() + { + using var scope = QueryTaggingContext.SetContext(); + Assert.NotNull(QueryTaggingContext.Current); + Assert.NotNull(QueryTaggingContext.Current!.FilePath); + Assert.Contains("QueryTaggingContextTests.cs", QueryTaggingContext.Current.FilePath); + } + + [Fact] + public void SetContext_CapturesCallerLineNumber() + { + using var scope = QueryTaggingContext.SetContext(); + Assert.NotNull(QueryTaggingContext.Current); + Assert.True(QueryTaggingContext.Current!.LineNumber > 0); + } + + [Fact] + public void SetContext_CapturesCallerMemberName() + { + using var scope = QueryTaggingContext.SetContext(); + Assert.NotNull(QueryTaggingContext.Current); + Assert.Equal("SetContext_CapturesCallerMemberName", QueryTaggingContext.Current!.MemberName); + } + + [Fact] + public void DoubleDispose_DoesNotThrow() + { + var scope = QueryTaggingContext.SetContext(action: "Test"); + scope.Dispose(); + scope.Dispose(); + } +} diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterIntegrationTests.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterIntegrationTests.cs new file mode 100644 index 00000000..5fdddd8b --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterIntegrationTests.cs @@ -0,0 +1,108 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace QueryDoctor.SqlCommenter.EFCore.Tests; + +public class SqlCommenterIntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public SqlCommenterIntegrationTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + public void Dispose() => _connection.Dispose(); + + private (TestDbContext context, SqlCapturingInterceptor captor) CreateContext( + Action? configure = null) + { + var captor = new SqlCapturingInterceptor(); + var optionsBuilder = new DbContextOptionsBuilder().UseSqlite(_connection); + if (configure != null) optionsBuilder.AddSqlCommenter(configure); + else optionsBuilder.AddSqlCommenter(o => o.EnableStackInspection = false); + optionsBuilder.AddInterceptors(captor); + var context = new TestDbContext(optionsBuilder.Options); + context.Database.EnsureCreated(); + captor.CapturedCommands.Clear(); + return (context, captor); + } + + [Fact] + public async Task RealQuery_AppendsDbDriverTag() + { + var (context, captor) = CreateContext(); + using (QueryTaggingContext.SetContext(action: "Query")) + { await context.Items.ToListAsync(); } + Assert.NotEmpty(captor.CapturedCommands); + var sql = captor.CapturedCommands.First(c => c.Contains("Items")); + Assert.Contains("db_driver='efcore'", sql); + } + + [Fact] + public async Task RealQuery_WithExplicitContext_IncludesControllerAndAction() + { + var (context, captor) = CreateContext(o => o.EnableStackInspection = false); + using (QueryTaggingContext.SetContext(action: "List", controller: "Items")) + { await context.Items.ToListAsync(); } + var sql = captor.CapturedCommands.First(c => c.Contains("Items")); + Assert.Contains("action='List'", sql); + Assert.Contains("controller='Items'", sql); + } + + [Fact] + public async Task Disabled_DoesNotAppendToRealQuery() + { + var (context, captor) = CreateContext(o => o.Enabled = false); + using (QueryTaggingContext.SetContext(action: "Test")) + { await context.Items.ToListAsync(); } + var sql = captor.CapturedCommands.First(c => c.Contains("Items")); + Assert.DoesNotContain("/*", sql); + } + + [Fact] + public async Task RealInsert_AppendsComment() + { + var (context, captor) = CreateContext(o => o.EnableStackInspection = false); + using (QueryTaggingContext.SetContext(action: "Create", controller: "Items")) + { + context.Items.Add(new TestItem { Name = "Test" }); + await context.SaveChangesAsync(); + } + var sql = captor.CapturedCommands.First(c => c.Contains("INSERT")); + Assert.Contains("db_driver='efcore'", sql); + Assert.Contains("action='Create'", sql); + } +} + +public class TestDbContext : DbContext +{ + public TestDbContext(DbContextOptions options) : base(options) { } + public DbSet Items => Set(); +} + +public class TestItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; +} + +public class SqlCapturingInterceptor : DbCommandInterceptor +{ + public List CapturedCommands { get; } = new(); + + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { CapturedCommands.Add(command.CommandText); return base.ReaderExecuting(command, eventData, result); } + + public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { CapturedCommands.Add(command.CommandText); return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } + + public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { CapturedCommands.Add(command.CommandText); return base.NonQueryExecuting(command, eventData, result); } + + public override ValueTask> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { CapturedCommands.Add(command.CommandText); return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); } +} diff --git a/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterInterceptorTests.cs b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterInterceptorTests.cs new file mode 100644 index 00000000..18303330 --- /dev/null +++ b/dotnet/sqlcommenter-efcore/test/QueryDoctor.SqlCommenter.EFCore.Tests/SqlCommenterInterceptorTests.cs @@ -0,0 +1,129 @@ +using System.Data.Common; +using NSubstitute; + +namespace QueryDoctor.SqlCommenter.EFCore.Tests; + +public class SqlCommenterInterceptorTests +{ + private static DbCommand CreateMockCommand(string sql) + { + var command = Substitute.For(); + command.CommandText = sql; + return command; + } + + [Fact] + public void ReaderExecuting_AppendsComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1"); + using (QueryTaggingContext.SetContext(action: "Test", controller: "Home")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Contains("/*", command.CommandText); + Assert.Contains("*/", command.CommandText); + Assert.StartsWith("SELECT 1", command.CommandText); + } + + [Fact] + public void ReaderExecutingAsync_AppendsComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1"); + using (QueryTaggingContext.SetContext(action: "Test", controller: "Home")) + { interceptor.ReaderExecutingAsync(command, null!, default); } + Assert.Contains("/*", command.CommandText); + } + + [Fact] + public void NonQueryExecuting_AppendsComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("INSERT INTO test VALUES (1)"); + using (QueryTaggingContext.SetContext(action: "Create", controller: "Home")) + { interceptor.NonQueryExecuting(command, null!, default); } + Assert.Contains("/*", command.CommandText); + Assert.StartsWith("INSERT INTO test VALUES (1)", command.CommandText); + } + + [Fact] + public void ScalarExecuting_AppendsComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT COUNT(*) FROM test"); + using (QueryTaggingContext.SetContext(action: "Count", controller: "Home")) + { interceptor.ScalarExecuting(command, null!, default); } + Assert.Contains("/*", command.CommandText); + } + + [Fact] + public void Disabled_DoesNotAppendComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { Enabled = false }); + var command = CreateMockCommand("SELECT 1"); + using (QueryTaggingContext.SetContext(action: "Test", controller: "Home")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Equal("SELECT 1", command.CommandText); + } + + [Fact] + public void ExistingCommentOpen_DoesNotAppendComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1 /* existing comment */"); + using (QueryTaggingContext.SetContext(action: "Test", controller: "Home")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Equal("SELECT 1 /* existing comment */", command.CommandText); + } + + [Fact] + public void ExistingCommentClose_DoesNotAppendComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1 */"); + using (QueryTaggingContext.SetContext(action: "Test", controller: "Home")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Equal("SELECT 1 */", command.CommandText); + } + + [Fact] + public void PreservesOriginalSql() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var originalSql = "SELECT * FROM users WHERE id = @p0"; + var command = CreateMockCommand(originalSql); + using (QueryTaggingContext.SetContext(action: "Get", controller: "Users")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.StartsWith(originalSql, command.CommandText); + } + + [Fact] + public void ExplicitContext_TakesPriority() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = true }); + var command = CreateMockCommand("SELECT 1"); + using (QueryTaggingContext.SetContext(action: "ExplicitAction", controller: "ExplicitController")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Contains("action='ExplicitAction'", command.CommandText); + Assert.Contains("controller='ExplicitController'", command.CommandText); + } + + [Fact] + public void NoContext_NoStackInspection_NoComment() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1"); + interceptor.ReaderExecuting(command, null!, default); + Assert.Equal("SELECT 1", command.CommandText); + } + + [Fact] + public void CommentContainsActionAndController() + { + var interceptor = new SqlCommenterInterceptor(new SqlCommenterOptions { EnableStackInspection = false }); + var command = CreateMockCommand("SELECT 1"); + using (QueryTaggingContext.SetContext(action: "MyAction", controller: "MyController")) + { interceptor.ReaderExecuting(command, null!, default); } + Assert.Contains("action='MyAction'", command.CommandText); + Assert.Contains("controller='MyController'", command.CommandText); + } +}