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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup Label="Package metadata">
<Authors>Luís Amorim</Authors>
<PackageVersion>0.3.0-preview.1</PackageVersion>
<PackageVersion>0.3.0-preview.2</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0003-verification-opt-in-by-snapshot-presence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 3. Verification is opted into by snapshot presence, and a missing snapshot is then a warning

## Status

Accepted

## Context

ADR 0001 deliberately left one question open: what should the analyzer report when a
`[SqlQuery]`/`[SqlExecute]` method has *no* `.sqlbound/` snapshot at all, as opposed to a
mismatched one? Under the signature-driven codegen chosen in ADR 0002 the generator does not need
snapshots, so a missing one is not inherently an error — the code still compiles and runs.

The tension is between two consumer populations:

- **Codegen-only consumers** adopt SqlBound for reflection-free materialization and may have no
database reachable at build time. Nagging every query with an unfixable diagnostic (fatal under
`TreatWarningsAsErrors`) would make the package hostile out of the box.
- **Verification adopters** run `prepare` and commit snapshots. For them, a query without a
snapshot is precisely the failure mode ADR 0001 worries about: someone added or edited a query
and forgot to re-run `prepare`, silently losing compile-time verification for that query.

A fixed severity cannot serve both: always-error breaks the first group, always-silent leaves the
second group's coverage hole open until CI runs `prepare --check` (M9).

## Decision

The committed `.sqlbound/` directory itself is the opt-in switch:

- **No `.sqlbound/` files wired as `AdditionalFiles` → the verification analyzer is fully
silent.** A project that never runs `prepare` never hears about verification.
- **Any `.sqlbound/` file present → verification is on for the whole project.** Every query
method without a matching snapshot reports `SQLB101` (warning), an unreadable or
command-text-mismatched snapshot reports `SQLB102` (warning), and matched snapshots are
compared normally (SQLB103–111).

"The team has opted into verification" is thereby a property of the repository — recorded by the
first committed snapshot — rather than of any per-project MSBuild flag that could drift between
projects and their snapshots.

Severities remain tunable per consumer via `.editorconfig`
(`dotnet_diagnostic.SQLB101.severity = error` for teams that want missing coverage to fail the
build).

## Consequences

**Positive**

- Zero-configuration adoption for codegen-only use; zero-configuration enforcement the moment the
first snapshot is committed.
- The forgot-to-re-run-`prepare` hole is surfaced in the IDE immediately, not first in CI.

**Negative / trade-offs**

- Deleting the whole `.sqlbound/` directory silently turns verification off; only M9's
`prepare --check` in CI can catch that. This is accepted — the same act is also the documented
way to genuinely opt out.
- The first `prepare` run flips every unprepared query in the project to SQLB101 at once. This is
the intended "now finish preparing" signal, but it can be noisy on large codebases adopting
verification incrementally; teams can downgrade SQLB101 via `.editorconfig` during migration.
52 changes: 52 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Diagnostics

All SqlBound diagnostics use the `SQLB` prefix. IDs below 100 are **usage** diagnostics reported
by the source generator — they validate how the attributes are used and never need a database.
IDs from 101 are **verification** diagnostics reported by the analyzer — they compare a method's
declared signature against the committed `.sqlbound/` snapshot for its SQL (see ADR 0001/0003).

Severities are defaults; consumers can retune any of them via `.editorconfig`
(`dotnet_diagnostic.SQLB101.severity = error`).

## Usage (`SqlBound.Usage`, generator)

| ID | Severity | Meaning |
|----|----------|---------|
| SQLB001 | Error | `[SqlQuery]`/`[SqlExecute]` method must be a partial definition without a body |
| SQLB002 | Error | Method must be static |
| SQLB003 | Error | Method must take a `DbConnection` (or derived) first parameter |
| SQLB004 | Error | Unsupported `[SqlQuery]` return type |
| SQLB005 | Error | Row type has no supported mapping (constructor or settable properties) |
| SQLB006 | Error | Query parameter type is not supported |
| SQLB007 | Error | Command text must not be empty |
| SQLB008 | Error | Method must not be generic or nested in a generic type |
| SQLB009 | Error | `[SqlExecute]` method must return `Task` or `Task<int>` |
| SQLB010 | Error | A method cannot carry both `[SqlQuery]` and `[SqlExecute]` |

## Verification (`SqlBound.Verification`, analyzer)

Reported only once the project has opted in by committing `.sqlbound/` snapshots (ADR 0003); a
project with no snapshots hears nothing.

| ID | Severity | Meaning |
|----|----------|---------|
| SQLB101 | Warning | Query has no snapshot — run the prepare step |
| SQLB102 | Warning | Snapshot is unreadable or no longer matches the command text — re-run prepare |
| SQLB103 | Error | Statement produces no result set, but the method expects one |
| SQLB104 | Error | Result set has no column with a declared name |
| SQLB105 | Error | Column CLR type differs from the declaration |
| SQLB106 | Error | Database column is nullable but declared non-nullable (the safe converse is silent) |
| SQLB107 | Info | Result set returns columns the method never reads |
| SQLB108 | Error | Statement uses a parameter the method does not declare |
| SQLB109 | Warning | Method declares a scalar parameter the statement never uses |
| SQLB110 | Error | Parameter CLR type differs from the declaration |
| SQLB111 | Warning | `[SqlExecute]` statement returns a result set it discards |

Two comparison rules worth knowing:

- **Columns match by case-insensitive name, not position** — generated code binds with
`GetOrdinal(name)`, so reordering a `SELECT` list is never a mismatch. Scalar-shaped methods
verify the first column instead.
- **Types compare as mapped CLR types, not SQL type names** — SQL Server's suggested parameter
types are inferences (a comparison against `decimal(18,2)` suggests `decimal(38,19)`), so raw
SQL type comparison would false-positive.
11 changes: 11 additions & 0 deletions src/SqlBound.Generators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ SQLB007 | SqlBound.Usage | Error | [SqlQuery] command text must not be empty
SQLB008 | SqlBound.Usage | Error | [SqlQuery]/[SqlExecute] method must not be generic or nested in a generic type
SQLB009 | SqlBound.Usage | Error | [SqlExecute] method must return Task or Task&lt;int&gt;
SQLB010 | SqlBound.Usage | Error | A method cannot carry both [SqlQuery] and [SqlExecute]
SQLB101 | SqlBound.Verification | Warning | Query has no .sqlbound snapshot (reported only once verification is opted in)
SQLB102 | SqlBound.Verification | Warning | Snapshot file is unreadable or stale
SQLB103 | SqlBound.Verification | Error | Statement produces no result set but the method expects one
SQLB104 | SqlBound.Verification | Error | Result set has no column with the declared name
SQLB105 | SqlBound.Verification | Error | Column CLR type differs from the declaration
SQLB106 | SqlBound.Verification | Error | Database column is nullable but declared non-nullable
SQLB107 | SqlBound.Verification | Info | Result set returns columns the method never reads
SQLB108 | SqlBound.Verification | Error | Statement uses a parameter the method does not declare
SQLB109 | SqlBound.Verification | Warning | Method declares a scalar parameter the statement never uses
SQLB110 | SqlBound.Verification | Error | Parameter CLR type differs from the declaration
SQLB111 | SqlBound.Verification | Warning | [SqlExecute] statement returns a result set it discards
20 changes: 15 additions & 5 deletions src/SqlBound.Generators/QueryMethodParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ internal static class QueryMethodParser
SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);

public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context, bool isExecute)
public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context, bool isExecute) =>
Parse(
(IMethodSymbol)context.TargetSymbol,
(MethodDeclarationSyntax)context.TargetNode,
context.Attributes[0],
context.SemanticModel.Compilation,
isExecute);

public static SqlQueryPipelineResult Parse(
IMethodSymbol symbol,
MethodDeclarationSyntax syntax,
AttributeData attribute,
Compilation compilation,
bool isExecute)
{
var symbol = (IMethodSymbol)context.TargetSymbol;
var syntax = (MethodDeclarationSyntax)context.TargetNode;
var location = LocationInfo.From(syntax.Identifier.GetLocation());
var compilation = context.SemanticModel.Compilation;
var attributeName = isExecute ? "SqlExecute" : "SqlQuery";
var diagnostics = new List<DiagnosticInfo>();

Expand All @@ -39,7 +49,7 @@ void Report(DiagnosticDescriptor descriptor, params string[] args) =>
return new SqlQueryPipelineResult(null, new EquatableArray<DiagnosticInfo>([.. diagnostics]));
}

var constructorArguments = context.Attributes[0].ConstructorArguments;
var constructorArguments = attribute.ConstructorArguments;
var commandText = constructorArguments.Length == 1 ? constructorArguments[0].Value as string : null;
if (string.IsNullOrWhiteSpace(commandText))
{
Expand Down
25 changes: 25 additions & 0 deletions src/SqlBound.Generators/QuerySnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace SqlBound.Generators;

/// <summary>
/// The analyzer-side view of one committed <c>.sqlbound/</c> snapshot: the database-described
/// metadata for a single command text, as written by the <c>prepare</c> step. Mirrors the
/// provider describe result (M7's <c>QueryDescription</c>) but lives here because the analyzer
/// targets netstandard2.0 and reads only JSON, never provider assemblies.
/// </summary>
internal sealed record QuerySnapshot(
string CommandText,
string Provider,
EquatableArray<SnapshotColumn> Columns,
EquatableArray<SnapshotParameter> Parameters);

internal sealed record SnapshotColumn(
int Ordinal,
string Name,
string SqlTypeName,
string ClrTypeText,
bool IsNullable);

internal sealed record SnapshotParameter(
string Name,
string SqlTypeName,
string ClrTypeText);
Loading
Loading