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.1.0</PackageVersion>
<PackageVersion>0.2.0-preview.1</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
48 changes: 44 additions & 4 deletions docs/adr/0002-generator-snapshot-consumption.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 2. Whether the source generator consumes `.sqlbound/` snapshots
# 2. The source generator emits code from the method signature, not from `.sqlbound/` snapshots

## Status

Proposed — to be decided before M4 (row materialization) starts.
Accepted

## Context

Expand All @@ -21,8 +21,48 @@ emitting `DbDataReader` materialization code. Two coherent designs exist:

## Decision

Not yet taken. To be resolved when M4 is planned, informed by generator prototyping.
**Signature-driven codegen.** The generator's only inputs are the `[SqlQuery]` attribute and the
declared C# partial method signature; it never reads `.sqlbound/` snapshots. Three reasons:

1. **The roadmap forces it.** Codegen (Phase 2) deliberately precedes verification (Phase 3)
because the generator defines the shapes the verifier checks. When row materialization (M4) is
built, no introspection, no `prepare` step, and no snapshot format exist yet — snapshot-driven
codegen is unimplementable until M7–M9. Choosing it would invert the phase order.
2. **It matches ADR 0001's DX stance.** The load-bearing decision there was that stale or missing
verification degrades gracefully instead of breaking the inner loop. Snapshot-driven codegen
re-couples every build to snapshot freshness — the exact failure mode ADR 0001 exists to avoid.
3. **The runtime cost is modest and recoverable.** Generated code resolves column ordinals once
via `GetOrdinal` before the read loop; per-row access is still straight-line typed getter
calls — no reflection, no IL emit. If profiling (M6) shows ordinal resolution matters,
snapshot-*informed* optimization can be layered on later without changing the public API (see
Consequences).

## Consequences

Pending decision.
**Positive**

- The build never depends on snapshot freshness; `.sqlbound/` remains purely a verification input,
keeping one mental model: generator = code shape, analyzer = correctness against the database.
- The generator stays a fast, deterministic function of the compilation alone, with no
`AdditionalFiles` coupling or cross-artifact cache invalidation concerns.
- Phase 2 can ship materialization end to end with zero verification infrastructure.

**Negative / trade-offs**

- A column/property mismatch (name or type) surfaces at **runtime** until Phase 3's analyzer
catches it at build time against the snapshots. This is the accepted trade-off; it is the
analyzer's job to close, not the generator's.
- Ordinal resolution happens once per query execution rather than being baked in at compile time.

**Possible future amendment (explicitly not a commitment)**

- If M6 benchmarks show `GetOrdinal` resolution to be a measurable cost, the generator may accept
snapshot metadata as an *optional* optimization input — falling back to name-based resolution
when absent, so a missing snapshot still never breaks the build. Any such change amends this
ADR.

**Interaction with ADR 0001's open point**

- The analyzer's behavior for a query with *no* snapshot (M8's diagnostic design) is now
unconstrained by codegen: since the build never needs snapshots, a missing one can be anything
from silence to a warning, chosen purely on verification-DX grounds.
2 changes: 2 additions & 0 deletions sqlbound.slnx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SqlBound.Generators/SqlBound.Generators.csproj" />
<Project Path="src/SqlBound/SqlBound.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj" />
<Project Path="test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj" />
<Project Path="test/SqlBound.UnitTests/SqlBound.UnitTests.csproj" />
</Folder>
Expand Down
2 changes: 2 additions & 0 deletions src/SqlBound.Generators/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
15 changes: 15 additions & 0 deletions src/SqlBound.Generators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------------------------------------------------------------------
SQLB001 | SqlBound.Usage | Error | [SqlQuery] method must be a partial definition without a body
SQLB002 | SqlBound.Usage | Error | [SqlQuery] method must be static
SQLB003 | SqlBound.Usage | Error | [SqlQuery] method must take a DbConnection (or derived) first parameter
SQLB004 | SqlBound.Usage | Error | [SqlQuery] method must return Task&lt;IReadOnlyList&lt;T&gt;&gt;
SQLB005 | SqlBound.Usage | Error | Row type must have one public constructor with supported column types
SQLB006 | SqlBound.Usage | Error | Query parameter type is not supported
SQLB007 | SqlBound.Usage | Error | [SqlQuery] command text must not be empty
SQLB008 | SqlBound.Usage | Error | [SqlQuery] method must not be generic or nested in a generic type
29 changes: 29 additions & 0 deletions src/SqlBound.Generators/DiagnosticInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SqlBound.Generators;

/// <summary>
/// A value-equatable stand-in for <see cref="Diagnostic"/> so diagnostics can travel through the
/// incremental pipeline without breaking model caching (<see cref="Diagnostic"/> itself holds
/// non-equatable state such as <see cref="Location"/>).
/// </summary>
internal sealed record DiagnosticInfo(
DiagnosticDescriptor Descriptor,
LocationInfo Location,
EquatableArray<string> MessageArgs)
{
public Diagnostic CreateDiagnostic() =>
Diagnostic.Create(Descriptor, Location.ToLocation(), MessageArgs.Cast<object>().ToArray());
}

/// <summary>The value-equatable pieces of a <see cref="Location"/>, for <see cref="DiagnosticInfo"/>.</summary>
internal sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
{
public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan);

public static LocationInfo From(Location location) => new(
location.SourceTree?.FilePath ?? string.Empty,
location.SourceSpan,
location.GetLineSpan().Span);
}
57 changes: 57 additions & 0 deletions src/SqlBound.Generators/EquatableArray.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Collections;

namespace SqlBound.Generators;

/// <summary>
/// An immutable array with structural (element-wise) equality, required for incremental
/// generator models: pipeline caching compares models by value, and a plain array or
/// <c>ImmutableArray</c> member would defeat it with reference equality.
/// </summary>
internal readonly struct EquatableArray<T>(T[] items) : IEquatable<EquatableArray<T>>, IEnumerable<T>
where T : IEquatable<T>
{
private readonly T[]? _items = items;

public static EquatableArray<T> Empty => new([]);

public int Count => _items?.Length ?? 0;

public T this[int index] => (_items ?? throw new InvalidOperationException("The array is empty."))[index];

public bool Equals(EquatableArray<T> other)
{
var left = _items ?? [];
var right = other._items ?? [];
if (left.Length != right.Length)
{
return false;
}

for (var i = 0; i < left.Length; i++)
{
if (!left[i].Equals(right[i]))
{
return false;
}
}

return true;
}

public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);

public override int GetHashCode()
{
var hash = 17;
foreach (var item in _items ?? [])
{
hash = unchecked((hash * 31) + item.GetHashCode());
}

return hash;
}

public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)(_items ?? [])).GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
7 changes: 7 additions & 0 deletions src/SqlBound.Generators/Polyfills/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Polyfill: records and init-only setters require this marker type, which netstandard2.0 lacks.
// Analyzer assemblies may carry no package dependencies, so it is declared here instead of
// referencing a polyfill package.

namespace System.Runtime.CompilerServices;

internal static class IsExternalInit;
Loading
Loading