Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
* **di:** `CachingBuilder.UseMemoryCache` / `UseDistributedRedis` / `UseMultiLevel` now register a single provider instance against both `ICacheProvider` (legacy) and `ICacheStore` (new). Consumers may inject either contract through 3.x.
* **docs:** Per-diagnostic reference pages under `docs/diagnostics/`: `QSPEC0001.md`, `QSPEC0002.md`, `QSPEC0003.md`. Each `[Obsolete]` attribute uses these as its `UrlFormat` target so IDE quick-info links resolve directly.
* **samples:** `samples/Migration/{GeoCoordinateMigration,FilterSpecMigration,CacheStoreMigration}` — three console programs showing the old usage (with the matching `#pragma warning disable QSPEC####`) followed by the new equivalent, including value-type caching and structural equality.
* **core:** `QuerySpec.Analyzers` — standalone Roslyn analyzer + code-fix package shipping `QSPEC0001` (`GeoLocation.Latitude`/`.Longitude` → `GeoCoordinate`), `QSPEC0002` (`AdvancedFilterExpression` → `FilterSpec`), and `QSPEC0003` (`ICacheProvider.GetAsync`/`SetAsync` → `ICacheStore.TryGetAsync`/`SetValueAsync`) diagnostics with one-click code fixes and Fix-All-In-Document/Project/Solution support. Targets `netstandard2.0` per Roslyn host requirements; consumed by adding `<PackageReference Include="QuerySpec.Analyzers" />` alongside the runtime packages. The analyzers suppress themselves when the target member already carries `[Obsolete(DiagnosticId = "QSPEC####")]` so consumers see exactly one warning per call site. Closes [#151](https://github.com/AbongileBoja/QuerySpec/issues/151).

### Deprecations

Expand Down
45 changes: 45 additions & 0 deletions QuerySpec.sln
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilterSpecMigration", "samp
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CacheStoreMigration", "samples\Migration\CacheStoreMigration\CacheStoreMigration.csproj", "{649580D0-5030-4968-8110-B75F84A9CC49}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers", "src\QuerySpec.Analyzers\QuerySpec.Analyzers.csproj", "{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers.CodeFixes", "src\QuerySpec.Analyzers.CodeFixes\QuerySpec.Analyzers.CodeFixes.csproj", "{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuerySpec.Analyzers.Tests", "tests\QuerySpec.Analyzers.Tests\QuerySpec.Analyzers.Tests.csproj", "{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -191,6 +197,42 @@ Global
{649580D0-5030-4968-8110-B75F84A9CC49}.Release|x64.Build.0 = Release|Any CPU
{649580D0-5030-4968-8110-B75F84A9CC49}.Release|x86.ActiveCfg = Release|Any CPU
{649580D0-5030-4968-8110-B75F84A9CC49}.Release|x86.Build.0 = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x64.ActiveCfg = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x64.Build.0 = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x86.ActiveCfg = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Debug|x86.Build.0 = Debug|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|Any CPU.Build.0 = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x64.ActiveCfg = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x64.Build.0 = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x86.ActiveCfg = Release|Any CPU
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D}.Release|x86.Build.0 = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x64.ActiveCfg = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x64.Build.0 = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x86.ActiveCfg = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Debug|x86.Build.0 = Debug|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|Any CPU.Build.0 = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x64.ActiveCfg = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x64.Build.0 = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x86.ActiveCfg = Release|Any CPU
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A}.Release|x86.Build.0 = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x64.ActiveCfg = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x64.Build.0 = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x86.ActiveCfg = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Debug|x86.Build.0 = Debug|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|Any CPU.Build.0 = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x64.ActiveCfg = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x64.Build.0 = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x86.ActiveCfg = Release|Any CPU
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -205,5 +247,8 @@ Global
{86062595-0822-4A1A-8A21-57F5B8C623E0} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3}
{E98B47F0-102F-470A-B40A-230D6A0C5BE7} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3}
{649580D0-5030-4968-8110-B75F84A9CC49} = {AC4D1969-34FE-CE3B-A6DC-185217944FF3}
{E6226DAB-342B-4E91-B9F8-FC4C7FBFFE5D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{634574EC-D5AA-4CE1-9D72-02D5AC09C10A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E282FC54-B2EF-4BCC-9F18-8D68EB04DF3F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace QuerySpec.Analyzers;

/// <summary>
/// Code fix for QSPEC0002: rewrites <c>new AdvancedFilterExpression { ... }</c> as
/// <c>new FilterSpec { ... }</c>. The two types share property names so the object initializer
/// transfers verbatim; the new type's accessors are <see langword="init"/> rather than
/// <see langword="set"/>, which is the migration's central guarantee.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AdvancedFilterExpressionCodeFixProvider))]
[Shared]
public sealed class AdvancedFilterExpressionCodeFixProvider : CodeFixProvider
{
private const string Title = "Use FilterSpec instead of AdvancedFilterExpression";

/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
ImmutableArray.Create(DiagnosticIds.AdvancedFilterExpressionUsage);

/// <inheritdoc/>
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <inheritdoc/>
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
if (root is null) return;

foreach (var diagnostic in context.Diagnostics)
{
var node = root.FindNode(diagnostic.Location.SourceSpan);
var creation = node.FirstAncestorOrSelf<ObjectCreationExpressionSyntax>();
if (creation is null) continue;

context.RegisterCodeFix(
CodeAction.Create(
title: Title,
createChangedDocument: ct => RewriteAsync(context.Document, creation, ct),
equivalenceKey: Title),
diagnostic);
}
}

private static async Task<Document> RewriteAsync(
Document document,
ObjectCreationExpressionSyntax creation,
CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null) return document;

var newTypeName = SyntaxFactory.IdentifierName("FilterSpec")
.WithTriviaFrom(creation.Type);

var replacement = creation.WithType(newTypeName);

var newRoot = root.ReplaceNode(creation, replacement);
return document.WithSyntaxRoot(newRoot);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;

namespace QuerySpec.Analyzers;

/// <summary>
/// Code fix for QSPEC0003: rewrites <c>cache.SetAsync&lt;T&gt;(...)</c> as
/// <c>cache.SetValueAsync&lt;T&gt;(...)</c>, and rewrites <c>await cache.GetAsync&lt;T&gt;(key)</c>
/// as <c>(await cache.TryGetAsync&lt;T&gt;(key)).GetValueOrDefault()</c> so the consumer keeps the
/// same null-fallback shape they had on the legacy API.
/// </summary>
/// <remarks>
/// The shipping providers (<c>MemoryCacheProvider</c>, <c>DistributedCacheProvider</c>,
/// <c>MultiLevelCache</c>) implement both <c>ICacheProvider</c> and <c>ICacheStore</c> through 3.x,
/// so the rewritten call resolves against the same instance: no DI changes required. Consumers
/// who explicitly typed their variable as <c>ICacheProvider</c> will get a compile error after the
/// fix because the new method does not exist on that interface; the diagnostic message and the
/// migration sample steer them to retype as <c>ICacheStore</c>. We deliberately do not auto-rewrite
/// the variable declaration because that would be a cross-file edit with subtle scoping
/// implications (DI registration, downstream usages, etc.) — that judgement stays with the
/// developer.
/// </remarks>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CacheProviderInvocationCodeFixProvider))]
[Shared]
public sealed class CacheProviderInvocationCodeFixProvider : CodeFixProvider
{
private const string Title = "Use ICacheStore instead of ICacheProvider";

/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
ImmutableArray.Create(DiagnosticIds.CacheProviderInvocation);

/// <inheritdoc/>
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <inheritdoc/>
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
if (root is null) return;

foreach (var diagnostic in context.Diagnostics)
{
var node = root.FindNode(diagnostic.Location.SourceSpan);
var memberAccess = node.FirstAncestorOrSelf<MemberAccessExpressionSyntax>();
if (memberAccess?.Parent is not InvocationExpressionSyntax invocation) continue;

context.RegisterCodeFix(
CodeAction.Create(
title: Title,
createChangedDocument: ct => RewriteAsync(context.Document, invocation, ct),
equivalenceKey: Title),
diagnostic);
}
}

private static async Task<Document> RewriteAsync(
Document document,
InvocationExpressionSyntax invocation,
CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null) return document;

if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return document;
}

var calledName = memberAccess.Name.Identifier.ValueText;
SyntaxNode? newRoot = calledName switch
{
"SetAsync" => RewriteSetAsync(root, invocation, memberAccess),
"GetAsync" => RewriteGetAsync(root, invocation, memberAccess),
_ => null,
};

return newRoot is null ? document : document.WithSyntaxRoot(newRoot);
}

private static SyntaxNode RewriteSetAsync(
SyntaxNode root,
InvocationExpressionSyntax invocation,
MemberAccessExpressionSyntax memberAccess)
{
var renamedName = ReplaceMethodIdentifier(memberAccess.Name, "SetValueAsync");
var newAccess = memberAccess.WithName(renamedName);
var replacement = invocation.WithExpression(newAccess);
return root.ReplaceNode(invocation, replacement);
}

private static SyntaxNode RewriteGetAsync(
SyntaxNode root,
InvocationExpressionSyntax invocation,
MemberAccessExpressionSyntax memberAccess)
{
var renamedName = ReplaceMethodIdentifier(memberAccess.Name, "TryGetAsync");
var newAccess = memberAccess.WithName(renamedName);
var renamedInvocation = invocation.WithExpression(newAccess);

// Wrap as: (await cache.TryGetAsync<T>(key)).GetValueOrDefault()
// If the original call site was already inside an `await` expression, replace the await
// with the parenthesised form so we keep one await and add the GetValueOrDefault hop.
if (invocation.Parent is AwaitExpressionSyntax existingAwait)
{
var awaited = existingAwait.WithExpression(renamedInvocation);
var parenthesised = SyntaxFactory.ParenthesizedExpression(awaited.WithoutTrivia());
var dotted = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
parenthesised,
SyntaxFactory.IdentifierName("GetValueOrDefault"));
var withFallback = SyntaxFactory.InvocationExpression(dotted)
.WithTriviaFrom(existingAwait)
.WithAdditionalAnnotations(Formatter.Annotation);
return root.ReplaceNode(existingAwait, withFallback);
}

// No surrounding await — emit a synchronous form that still composes with .Result-style
// patterns the developer may be using. We add the .GetValueOrDefault() hop via a
// continuation so the rewrite remains semantically consistent.
var awaitExpr = SyntaxFactory.AwaitExpression(renamedInvocation.WithoutTrivia());
var paren = SyntaxFactory.ParenthesizedExpression(awaitExpr);
var member = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
paren,
SyntaxFactory.IdentifierName("GetValueOrDefault"));
var newCall = SyntaxFactory.InvocationExpression(member)
.WithTriviaFrom(invocation)
.WithAdditionalAnnotations(Formatter.Annotation);
return root.ReplaceNode(invocation, newCall);
}

private static SimpleNameSyntax ReplaceMethodIdentifier(SimpleNameSyntax original, string newName)
{
if (original is GenericNameSyntax generic)
{
return generic.WithIdentifier(SyntaxFactory.Identifier(newName));
}

if (original is IdentifierNameSyntax identifier)
{
return identifier.WithIdentifier(SyntaxFactory.Identifier(newName));
}

return SyntaxFactory.IdentifierName(newName).WithTriviaFrom(original);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace QuerySpec.Analyzers;

/// <summary>
/// Code fix for QSPEC0001: rewrites <c>legacy.Latitude</c> as <c>legacy.ToGeoCoordinate().Latitude</c>
/// (and likewise for <c>Longitude</c>). The migration helper is intent-revealing, allocation-free
/// (<c>GeoCoordinate</c> is a <see langword="readonly"/> <see langword="record"/> <see langword="struct"/>),
/// and validates inputs.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(GeoLocationMemberAccessCodeFixProvider))]
[Shared]
public sealed class GeoLocationMemberAccessCodeFixProvider : CodeFixProvider
{
private const string Title = "Use GeoCoordinate instead of GeoLocation";

/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds { get; } =
ImmutableArray.Create(DiagnosticIds.GeoLocationMemberAccess);

/// <inheritdoc/>
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <inheritdoc/>
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
if (root is null) return;

foreach (var diagnostic in context.Diagnostics)
{
var nameSyntax = root.FindNode(diagnostic.Location.SourceSpan);
var memberAccess = nameSyntax.FirstAncestorOrSelf<MemberAccessExpressionSyntax>();
if (memberAccess is null) continue;

context.RegisterCodeFix(
CodeAction.Create(
title: Title,
createChangedDocument: ct => RewriteAsync(context.Document, memberAccess, ct),
equivalenceKey: Title),
diagnostic);
}
}

private static async Task<Document> RewriteAsync(
Document document,
MemberAccessExpressionSyntax memberAccess,
CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null) return document;

var receiver = memberAccess.Expression;
var memberName = memberAccess.Name;

var helperCall = SyntaxFactory.InvocationExpression(
SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
receiver.WithoutTrivia(),
SyntaxFactory.IdentifierName("ToGeoCoordinate")));

var replacement = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
helperCall,
memberName.WithoutTrivia())
.WithTriviaFrom(memberAccess);

var newRoot = root.ReplaceNode(memberAccess, replacement);
return document.WithSyntaxRoot(newRoot);
}
}
Loading
Loading