diff --git a/Directory.Build.props b/Directory.Build.props index 26c121d..12e67d9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.1.0 + 0.2.0-preview.1 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound diff --git a/docs/adr/0002-generator-snapshot-consumption.md b/docs/adr/0002-generator-snapshot-consumption.md index 8567ccb..d74f2af 100644 --- a/docs/adr/0002-generator-snapshot-consumption.md +++ b/docs/adr/0002-generator-snapshot-consumption.md @@ -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 @@ -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. diff --git a/sqlbound.slnx b/sqlbound.slnx index 83892d5..bc03119 100644 --- a/sqlbound.slnx +++ b/sqlbound.slnx @@ -1,8 +1,10 @@ + + diff --git a/src/SqlBound.Generators/AnalyzerReleases.Shipped.md b/src/SqlBound.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/SqlBound.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..2fc436c --- /dev/null +++ b/src/SqlBound.Generators/AnalyzerReleases.Unshipped.md @@ -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<IReadOnlyList<T>> +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 diff --git a/src/SqlBound.Generators/DiagnosticInfo.cs b/src/SqlBound.Generators/DiagnosticInfo.cs new file mode 100644 index 0000000..b156a75 --- /dev/null +++ b/src/SqlBound.Generators/DiagnosticInfo.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SqlBound.Generators; + +/// +/// A value-equatable stand-in for so diagnostics can travel through the +/// incremental pipeline without breaking model caching ( itself holds +/// non-equatable state such as ). +/// +internal sealed record DiagnosticInfo( + DiagnosticDescriptor Descriptor, + LocationInfo Location, + EquatableArray MessageArgs) +{ + public Diagnostic CreateDiagnostic() => + Diagnostic.Create(Descriptor, Location.ToLocation(), MessageArgs.Cast().ToArray()); +} + +/// The value-equatable pieces of a , for . +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); +} diff --git a/src/SqlBound.Generators/EquatableArray.cs b/src/SqlBound.Generators/EquatableArray.cs new file mode 100644 index 0000000..23f588a --- /dev/null +++ b/src/SqlBound.Generators/EquatableArray.cs @@ -0,0 +1,57 @@ +using System.Collections; + +namespace SqlBound.Generators; + +/// +/// An immutable array with structural (element-wise) equality, required for incremental +/// generator models: pipeline caching compares models by value, and a plain array or +/// ImmutableArray member would defeat it with reference equality. +/// +internal readonly struct EquatableArray(T[] items) : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly T[]? _items = items; + + public static EquatableArray 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 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 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 GetEnumerator() => ((IEnumerable)(_items ?? [])).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/SqlBound.Generators/Polyfills/IsExternalInit.cs b/src/SqlBound.Generators/Polyfills/IsExternalInit.cs new file mode 100644 index 0000000..b044ab4 --- /dev/null +++ b/src/SqlBound.Generators/Polyfills/IsExternalInit.cs @@ -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; diff --git a/src/SqlBound.Generators/QueryMethodEmitter.cs b/src/SqlBound.Generators/QueryMethodEmitter.cs new file mode 100644 index 0000000..952ce7c --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodEmitter.cs @@ -0,0 +1,206 @@ +using System.Text; + +namespace SqlBound.Generators; + +/// +/// Emits the implementation part of one [SqlQuery] method from its +/// : straight-line DbDataReader code with ordinals resolved +/// once before the read loop, per-column IsDBNull handling, and no reflection. +/// +internal static class QueryMethodEmitter +{ + public static string GetHintName(QueryMethodModel model) + { + var builder = new StringBuilder(); + if (model.Namespace.Length > 0) + { + builder.Append(model.Namespace).Append('.'); + } + + foreach (var type in model.ContainingTypes) + { + builder.Append(type.Name).Append('.'); + } + + return builder + .Append(model.MethodName).Append('.') + .Append(SignatureHash(model)) + .Append(".g.cs") + .ToString(); + } + + public static string EmitSource(QueryMethodModel model) + { + var builder = new StringBuilder(); + builder.Append("// \n"); + builder.Append("// Emitted by the SqlBound source generator. Do not edit.\n"); + builder.Append("#nullable enable\n\n"); + if (model.Namespace.Length > 0) + { + builder.Append("namespace ").Append(model.Namespace).Append(";\n\n"); + } + + void Line(int level, string text) + { + if (text.Length > 0) + { + builder.Append(new string(' ', level * 4)).Append(text); + } + + builder.Append('\n'); + } + + var depth = 0; + foreach (var type in model.ContainingTypes) + { + Line(depth, $"partial {type.Keyword} {type.Name}"); + Line(depth, "{"); + depth++; + } + + EmitMethod(model, depth, Line); + + for (var i = model.ContainingTypes.Count - 1; i >= 0; i--) + { + Line(i, "}"); + } + + return builder.ToString(); + } + + private static void EmitMethod(QueryMethodModel model, int depth, Action line) + { + var connection = ParameterName(model, ParameterKind.Connection); + var transaction = ParameterNameOrNull(model, ParameterKind.Transaction); + var cancellationToken = ParameterNameOrNull(model, ParameterKind.CancellationToken) + ?? "global::System.Threading.CancellationToken.None"; + var returnType = "global::System.Threading.Tasks.Task<" + + $"global::System.Collections.Generic.IReadOnlyList<{model.RowTypeText}>>"; + + var signatureParameters = new List(); + foreach (var parameter in model.Parameters) + { + var prefix = model.IsExtensionMethod && signatureParameters.Count == 0 ? "this " : ""; + signatureParameters.Add($"{prefix}{parameter.TypeText} {parameter.Name}"); + } + + line(depth, $"{model.Accessibility} static async partial {returnType} " + + $"{model.MethodName}({string.Join(", ", signatureParameters)})"); + line(depth, "{"); + line(depth + 1, $"if ({connection} is null)"); + line(depth + 1, "{"); + line(depth + 2, $"throw new global::System.ArgumentNullException(nameof({connection}));"); + line(depth + 1, "}"); + line(depth + 1, ""); + line(depth + 1, $"global::System.Data.Common.DbCommand __command = {connection}.CreateCommand();"); + line(depth + 1, "try"); + line(depth + 1, "{"); + line(depth + 2, $"__command.CommandText = @\"{model.CommandText.Replace("\"", "\"\"")}\";"); + if (transaction is not null) + { + line(depth + 2, $"__command.Transaction = {transaction};"); + } + + var scalarIndex = 0; + foreach (var parameter in model.Parameters) + { + if (parameter.Kind != ParameterKind.Scalar) + { + continue; + } + + var value = parameter.CanBeNull + ? $"(object?){parameter.Name} ?? global::System.DBNull.Value" + : parameter.Name; + line(depth + 2, $"global::System.Data.Common.DbParameter __parameter{scalarIndex} = __command.CreateParameter();"); + line(depth + 2, $"__parameter{scalarIndex}.ParameterName = \"@{parameter.Name}\";"); + line(depth + 2, $"__parameter{scalarIndex}.Value = {value};"); + line(depth + 2, $"__command.Parameters.Add(__parameter{scalarIndex});"); + scalarIndex++; + } + + line(depth + 2, "global::System.Data.Common.DbDataReader __reader = " + + $"await __command.ExecuteReaderAsync({cancellationToken}).ConfigureAwait(false);"); + line(depth + 2, "try"); + line(depth + 2, "{"); + for (var i = 0; i < model.Columns.Count; i++) + { + line(depth + 3, $"int __ordinal{i} = __reader.GetOrdinal(\"{model.Columns[i].Name}\");"); + } + + line(depth + 3, $"global::System.Collections.Generic.List<{model.RowTypeText}> __rows = new();"); + line(depth + 3, $"while (await __reader.ReadAsync({cancellationToken}).ConfigureAwait(false))"); + line(depth + 3, "{"); + line(depth + 4, $"__rows.Add(new {model.RowTypeText}("); + for (var i = 0; i < model.Columns.Count; i++) + { + var terminator = i == model.Columns.Count - 1 ? "));" : ","; + line(depth + 5, ReadColumn(model.Columns[i], i) + terminator); + } + + line(depth + 3, "}"); + line(depth + 3, ""); + line(depth + 3, "return __rows;"); + line(depth + 2, "}"); + line(depth + 2, "finally"); + line(depth + 2, "{"); + line(depth + 3, "await __reader.DisposeAsync().ConfigureAwait(false);"); + line(depth + 2, "}"); + line(depth + 1, "}"); + line(depth + 1, "finally"); + line(depth + 1, "{"); + line(depth + 2, "await __command.DisposeAsync().ConfigureAwait(false);"); + line(depth + 1, "}"); + line(depth, "}"); + } + + private static string ReadColumn(ColumnModel column, int index) + { + var read = $"__reader.{column.GetterInvocation}(__ordinal{index})"; + if (column.IsNullable) + { + return $"__reader.IsDBNull(__ordinal{index}) ? default({column.TypeText}) : {read}"; + } + + return $"__reader.IsDBNull(__ordinal{index}) " + + "? throw new global::System.InvalidOperationException(" + + $"\"Column '{column.Name}' is NULL but maps to non-nullable '{column.TypeText}'.\") " + + $": {read}"; + } + + private static string ParameterName(QueryMethodModel model, ParameterKind kind) => + ParameterNameOrNull(model, kind) + ?? throw new InvalidOperationException($"The model has no {kind} parameter."); + + private static string? ParameterNameOrNull(QueryMethodModel model, ParameterKind kind) + { + foreach (var parameter in model.Parameters) + { + if (parameter.Kind == kind) + { + return parameter.Name; + } + } + + return null; + } + + private static string SignatureHash(QueryMethodModel model) + { + var signature = new StringBuilder(model.MethodName); + foreach (var parameter in model.Parameters) + { + signature.Append(',').Append(parameter.TypeText); + } + + // FNV-1a: deterministic across processes, unlike string.GetHashCode, so the hint name + // (and with it the generated output) is stable build over build. + var hash = 2166136261u; + foreach (var character in signature.ToString()) + { + hash = unchecked((hash ^ character) * 16777619u); + } + + return hash.ToString("x8"); + } +} diff --git a/src/SqlBound.Generators/QueryMethodModel.cs b/src/SqlBound.Generators/QueryMethodModel.cs new file mode 100644 index 0000000..0f3a22f --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodModel.cs @@ -0,0 +1,37 @@ +namespace SqlBound.Generators; + +/// +/// The value-equatable model of one valid [SqlQuery] method, extracted from the semantic +/// model in the pipeline's transform step. Emission works exclusively from this model so that +/// incremental caching can skip regeneration whenever the model is unchanged. +/// +internal sealed record QueryMethodModel( + string Namespace, + EquatableArray ContainingTypes, + string Accessibility, + string MethodName, + bool IsExtensionMethod, + string CommandText, + string RowTypeText, + EquatableArray Columns, + EquatableArray Parameters); + +/// One type in the (outermost-first) declaration chain wrapping the query method. +internal sealed record ContainingTypeModel(string Keyword, string Name); + +/// One row-type constructor parameter, read from the result set column of the same name. +internal sealed record ColumnModel(string Name, string TypeText, string GetterInvocation, bool IsNullable); + +/// One parameter of the query method, classified by the role it plays in the generated body. +/// is meaningful for scalars only: it decides whether binding +/// coalesces the argument to DBNull.Value. +internal sealed record MethodParameterModel(string Name, string TypeText, ParameterKind Kind, bool CanBeNull = false); + +/// The role a method parameter plays in the generated implementation. +internal enum ParameterKind +{ + Connection, + Transaction, + CancellationToken, + Scalar, +} diff --git a/src/SqlBound.Generators/QueryMethodParser.cs b/src/SqlBound.Generators/QueryMethodParser.cs new file mode 100644 index 0000000..b9a94dc --- /dev/null +++ b/src/SqlBound.Generators/QueryMethodParser.cs @@ -0,0 +1,261 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace SqlBound.Generators; + +/// +/// Turns one [SqlQuery] attribute occurrence into a : +/// a when the method is well-formed, usage diagnostics otherwise. +/// +internal static class QueryMethodParser +{ + private static readonly SymbolDisplayFormat TypeTextFormat = + SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public static SqlQueryPipelineResult Parse(GeneratorAttributeSyntaxContext context) + { + var symbol = (IMethodSymbol)context.TargetSymbol; + var syntax = (MethodDeclarationSyntax)context.TargetNode; + var location = LocationInfo.From(syntax.Identifier.GetLocation()); + var compilation = context.SemanticModel.Compilation; + var diagnostics = new List(); + + void Report(DiagnosticDescriptor descriptor, params string[] args) => + diagnostics.Add(new DiagnosticInfo(descriptor, location, new EquatableArray(args))); + + var constructorArguments = context.Attributes[0].ConstructorArguments; + var commandText = constructorArguments.Length == 1 ? constructorArguments[0].Value as string : null; + if (string.IsNullOrWhiteSpace(commandText)) + { + Report(SqlQueryDiagnostics.CommandTextMustNotBeEmpty); + } + + var isPartialDefinition = syntax.Modifiers.Any(SyntaxKind.PartialKeyword) + && syntax.Body is null + && syntax.ExpressionBody is null; + if (!isPartialDefinition || symbol.PartialImplementationPart is not null) + { + Report(SqlQueryDiagnostics.MethodMustBePartialDefinition, symbol.Name); + } + + if (!symbol.IsStatic) + { + Report(SqlQueryDiagnostics.MethodMustBeStatic, symbol.Name); + } + + if (symbol.Arity > 0 || ContainingTypeChain(symbol).Any(type => type.Arity > 0)) + { + Report(SqlQueryDiagnostics.GenericDeclarationsNotSupported, symbol.Name); + } + + var dbConnectionType = compilation.GetTypeByMetadataName("System.Data.Common.DbConnection"); + var dbTransactionType = compilation.GetTypeByMetadataName("System.Data.Common.DbTransaction"); + var cancellationTokenType = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); + var guidType = compilation.GetTypeByMetadataName("System.Guid"); + + var parameters = new List(); + var methodParameters = symbol.Parameters; + if (methodParameters.Length == 0 || !DerivesFrom(methodParameters[0].Type, dbConnectionType)) + { + Report(SqlQueryDiagnostics.MethodMustTakeDbConnectionFirst, symbol.Name); + } + else + { + parameters.Add(new MethodParameterModel( + methodParameters[0].Name, TypeText(methodParameters[0].Type), ParameterKind.Connection)); + } + + for (var i = 1; i < methodParameters.Length; i++) + { + var parameter = methodParameters[i]; + var typeText = TypeText(parameter.Type); + if (parameter.RefKind != RefKind.None) + { + Report(SqlQueryDiagnostics.UnsupportedQueryParameterType, parameter.Name, typeText); + } + else if (i == 1 && DerivesFrom(StripNullable(parameter.Type), dbTransactionType)) + { + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Transaction)); + } + else if (i == methodParameters.Length - 1 + && SymbolEqualityComparer.Default.Equals(parameter.Type, cancellationTokenType)) + { + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.CancellationToken)); + } + else if (TryGetGetter(parameter.Type, guidType, out _)) + { + var canBeNull = parameter.Type.IsReferenceType || IsNullable(parameter.Type); + parameters.Add(new MethodParameterModel(parameter.Name, typeText, ParameterKind.Scalar, canBeNull)); + } + else + { + Report(SqlQueryDiagnostics.UnsupportedQueryParameterType, parameter.Name, typeText); + } + } + + var taskType = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + var readOnlyListType = compilation.GetTypeByMetadataName("System.Collections.Generic.IReadOnlyList`1"); + ITypeSymbol? rowType = null; + if (symbol.ReturnType is INamedTypeSymbol task + && SymbolEqualityComparer.Default.Equals(task.OriginalDefinition, taskType) + && task.TypeArguments[0] is INamedTypeSymbol list + && SymbolEqualityComparer.Default.Equals(list.OriginalDefinition, readOnlyListType)) + { + rowType = list.TypeArguments[0]; + } + else + { + Report(SqlQueryDiagnostics.UnsupportedReturnType, symbol.Name, symbol.ReturnType.ToDisplayString()); + } + + ColumnModel[] columns = rowType is null ? [] : ParseColumns(rowType, guidType, Report); + + if (diagnostics.Count > 0) + { + return new SqlQueryPipelineResult(null, new EquatableArray([.. diagnostics])); + } + + var containingTypes = ContainingTypeChain(symbol) + .Reverse() + .Select(type => new ContainingTypeModel(TypeKeyword(type), type.Name)) + .ToArray(); + + var model = new QueryMethodModel( + symbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : symbol.ContainingNamespace.ToDisplayString(), + new EquatableArray(containingTypes), + AccessibilityText(symbol.DeclaredAccessibility), + symbol.Name, + symbol.IsExtensionMethod, + commandText!, + rowType!.ToDisplayString(TypeTextFormat), + new EquatableArray(columns), + new EquatableArray([.. parameters])); + return new SqlQueryPipelineResult(model, EquatableArray.Empty); + } + + private static ColumnModel[] ParseColumns( + ITypeSymbol rowType, + INamedTypeSymbol? guidType, + Action report) + { + var rowTypeText = rowType.ToDisplayString(); + + if (rowType is not INamedTypeSymbol named + || named.IsAbstract + || named.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + var constructors = named.InstanceConstructors + .Where(ctor => ctor.DeclaredAccessibility == Accessibility.Public && ctor.Parameters.Length > 0) + .ToArray(); + if (constructors.Length != 1) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + var columns = new List(); + foreach (var parameter in constructors[0].Parameters) + { + if (!TryGetGetter(parameter.Type, guidType, out var getter)) + { + report(SqlQueryDiagnostics.UnsupportedRowType, [rowTypeText]); + return []; + } + + columns.Add(new ColumnModel( + parameter.Name, + parameter.Type.ToDisplayString(TypeTextFormat), + getter!, + IsNullable(parameter.Type))); + } + + return [.. columns]; + } + + private static bool TryGetGetter(ITypeSymbol type, INamedTypeSymbol? guidType, out string? getter) + { + var underlying = StripNullable(type); + getter = underlying.SpecialType switch + { + SpecialType.System_Int32 => "GetInt32", + SpecialType.System_Int64 => "GetInt64", + SpecialType.System_Int16 => "GetInt16", + SpecialType.System_Byte => "GetByte", + SpecialType.System_Boolean => "GetBoolean", + SpecialType.System_String => "GetString", + SpecialType.System_Double => "GetDouble", + SpecialType.System_Single => "GetFloat", + SpecialType.System_Decimal => "GetDecimal", + SpecialType.System_DateTime => "GetDateTime", + _ when SymbolEqualityComparer.Default.Equals(underlying, guidType) => "GetGuid", + _ when underlying is IArrayTypeSymbol { IsSZArray: true, ElementType.SpecialType: SpecialType.System_Byte } + => "GetFieldValue", + _ => null, + }; + return getter is not null; + } + + private static bool IsNullable(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.Annotated); + + private static ITypeSymbol StripNullable(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type; + + private static bool DerivesFrom(ITypeSymbol type, INamedTypeSymbol? baseType) + { + if (baseType is null) + { + return false; + } + + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + + return false; + } + + private static IEnumerable ContainingTypeChain(IMethodSymbol symbol) + { + for (var type = symbol.ContainingType; type is not null; type = type.ContainingType) + { + yield return type; + } + } + + private static string TypeText(ITypeSymbol type) => type.ToDisplayString(TypeTextFormat); + + private static string TypeKeyword(INamedTypeSymbol type) => type switch + { + { IsRecord: true, TypeKind: TypeKind.Struct } => "record struct", + { IsRecord: true } => "record", + { TypeKind: TypeKind.Struct } => "struct", + { TypeKind: TypeKind.Interface } => "interface", + _ => "class", + }; + + private static string AccessibilityText(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private", + }; +} diff --git a/src/SqlBound.Generators/SqlBound.Generators.csproj b/src/SqlBound.Generators/SqlBound.Generators.csproj new file mode 100644 index 0000000..6839967 --- /dev/null +++ b/src/SqlBound.Generators/SqlBound.Generators.csproj @@ -0,0 +1,35 @@ + + + + + + netstandard2.0 + true + true + false + true + + false + + $(NoWarn);NU5128 + + + + + + + + + + + + + + + + + diff --git a/src/SqlBound.Generators/SqlQueryDiagnostics.cs b/src/SqlBound.Generators/SqlQueryDiagnostics.cs new file mode 100644 index 0000000..fee34db --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryDiagnostics.cs @@ -0,0 +1,73 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators; + +/// The SQLB0## descriptors for invalid [SqlQuery] usage reported by the generator. +internal static class SqlQueryDiagnostics +{ + private const string Category = "SqlBound.Usage"; + + public static readonly DiagnosticDescriptor MethodMustBePartialDefinition = new( + "SQLB001", + "Method must be a partial definition", + "Method '{0}' marked with [SqlQuery] must be a partial method definition with no body and no separate implementation part", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodMustBeStatic = new( + "SQLB002", + "Method must be static", + "Method '{0}' marked with [SqlQuery] must be static", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodMustTakeDbConnectionFirst = new( + "SQLB003", + "Method must take a DbConnection first", + "Method '{0}' marked with [SqlQuery] must declare a System.Data.Common.DbConnection (or derived) first parameter", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnsupportedReturnType = new( + "SQLB004", + "Unsupported return type", + "Method '{0}' returns '{1}' but [SqlQuery] methods must return Task> where T is a supported row type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnsupportedRowType = new( + "SQLB005", + "Unsupported row type", + "Row type '{0}' must expose exactly one public constructor with at least one parameter, and every constructor parameter must be of a supported column type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnsupportedQueryParameterType = new( + "SQLB006", + "Unsupported query parameter type", + "Parameter '{0}' of type '{1}' is not supported; query parameters must be supported scalar types, with an optional DbTransaction second parameter and an optional trailing CancellationToken", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor CommandTextMustNotBeEmpty = new( + "SQLB007", + "Command text must not be empty", + "The [SqlQuery] command text must not be null, empty, or whitespace", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor GenericDeclarationsNotSupported = new( + "SQLB008", + "Generic declarations are not supported", + "Method '{0}' marked with [SqlQuery] must not be generic or declared within a generic type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); +} diff --git a/src/SqlBound.Generators/SqlQueryGenerator.cs b/src/SqlBound.Generators/SqlQueryGenerator.cs new file mode 100644 index 0000000..d61eedf --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryGenerator.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace SqlBound.Generators; + +/// +/// Incremental source generator that implements [SqlQuery]-annotated static partial +/// methods with straight-line, reflection-free DbDataReader materialization code. Per +/// ADR 0002 the generator is signature-driven: its only inputs are the attribute and the declared +/// method signature — it never reads .sqlbound/ snapshots or performs I/O. +/// +[Generator(LanguageNames.CSharp)] +public sealed class SqlQueryGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var results = context.SyntaxProvider.ForAttributeWithMetadataName( + "SqlBound.SqlQueryAttribute", + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, _) => QueryMethodParser.Parse(attributeContext)); + + context.RegisterSourceOutput(results, static (outputContext, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + outputContext.ReportDiagnostic(diagnostic.CreateDiagnostic()); + } + + if (result.Method is { } method) + { + outputContext.AddSource( + QueryMethodEmitter.GetHintName(method), + QueryMethodEmitter.EmitSource(method)); + } + }); + } +} diff --git a/src/SqlBound.Generators/SqlQueryPipelineResult.cs b/src/SqlBound.Generators/SqlQueryPipelineResult.cs new file mode 100644 index 0000000..26ec939 --- /dev/null +++ b/src/SqlBound.Generators/SqlQueryPipelineResult.cs @@ -0,0 +1,9 @@ +namespace SqlBound.Generators; + +/// +/// The outcome of parsing one [SqlQuery] method: either a model to emit from, or the usage +/// diagnostics explaining why no implementation can be generated. +/// +internal sealed record SqlQueryPipelineResult( + QueryMethodModel? Method, + EquatableArray Diagnostics); diff --git a/src/SqlBound/SqlQueryAttribute.cs b/src/SqlBound/SqlQueryAttribute.cs new file mode 100644 index 0000000..62eed77 --- /dev/null +++ b/src/SqlBound/SqlQueryAttribute.cs @@ -0,0 +1,14 @@ +namespace SqlBound; + +/// +/// Marks a static partial method whose implementation is emitted by the SqlBound source +/// generator: the method executes and materializes the result +/// rows into the method's declared return type with straight-line, reflection-free reader code. +/// +/// The SQL statement the generated implementation executes. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SqlQueryAttribute(string commandText) : Attribute +{ + /// Gets the SQL statement the generated implementation executes. + public string CommandText { get; } = commandText ?? throw new ArgumentNullException(nameof(commandText)); +} diff --git a/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs new file mode 100644 index 0000000..51b098a --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/GeneratorHarness.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SqlBound.Generators.UnitTests; + +/// +/// Runs against an in-memory compilation via the Roslyn +/// , the snapshot-testing entry point for all generator tests. +/// +internal static class GeneratorHarness +{ + private static readonly IReadOnlyList References = CreateReferences(); + + public static GeneratorRunOutcome Run(string source) + { + var compilation = CSharpCompilation.Create( + "SqlBound.Generators.UnitTests.Target", + [CSharpSyntaxTree.ParseText(source)], + References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + var driver = CSharpGeneratorDriver + .Create(new SqlQueryGenerator()) + .RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out _); + + var result = driver.GetRunResult().Results.Single(); + return new GeneratorRunOutcome( + result.GeneratedSources, + result.Diagnostics, + updatedCompilation.GetDiagnostics()); + } + + private static IReadOnlyList CreateReferences() + { + var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + var references = trustedAssemblies + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToList(); + references.Add(MetadataReference.CreateFromFile(typeof(SqlQueryAttribute).Assembly.Location)); + return references; + } +} + +/// The observable output of one generator run: sources, generator diagnostics, and the +/// diagnostics of the compilation after the generated sources were added to it. +internal sealed record GeneratorRunOutcome( + IReadOnlyList GeneratedSources, + IReadOnlyList GeneratorDiagnostics, + IReadOnlyList CompilationDiagnostics); diff --git a/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs new file mode 100644 index 0000000..f28d045 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/QueryMethodEmissionTests.cs @@ -0,0 +1,231 @@ +using Microsoft.CodeAnalysis; + +namespace SqlBound.Generators.UnitTests; + +public class QueryMethodEmissionTests +{ + private const string Prelude = """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public sealed record Item(int Id, string Name, decimal? Price); + + """; + + private const string CanonicalSource = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, + DbTransaction? transaction, + string category, + CancellationToken cancellationToken = default); + } + """; + + [Fact] + public void Should_EmitSingleImplementationFile_When_MethodIsWellFormed() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + var generated = Assert.Single(outcome.GeneratedSources); + Assert.StartsWith("App.ItemQueries.GetItemsAsync.", generated.HintName); + Assert.EndsWith(".g.cs", generated.HintName); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_MethodIsWellFormed() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + AssertCompilesClean(outcome); + } + + [Fact] + public void Should_MatchExpectedSource_When_CanonicalQueryIsGenerated() + { + const string expected = """ + // + // Emitted by the SqlBound source generator. Do not edit. + #nullable enable + + namespace App; + + partial class ItemQueries + { + public static async partial global::System.Threading.Tasks.Task> GetItemsAsync(global::System.Data.Common.DbConnection connection, global::System.Data.Common.DbTransaction? transaction, string category, global::System.Threading.CancellationToken cancellationToken) + { + if (connection is null) + { + throw new global::System.ArgumentNullException(nameof(connection)); + } + + global::System.Data.Common.DbCommand __command = connection.CreateCommand(); + try + { + __command.CommandText = @"SELECT id, name, price FROM items WHERE category = @category"; + __command.Transaction = transaction; + global::System.Data.Common.DbParameter __parameter0 = __command.CreateParameter(); + __parameter0.ParameterName = "@category"; + __parameter0.Value = (object?)category ?? global::System.DBNull.Value; + __command.Parameters.Add(__parameter0); + global::System.Data.Common.DbDataReader __reader = await __command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + try + { + int __ordinal0 = __reader.GetOrdinal("Id"); + int __ordinal1 = __reader.GetOrdinal("Name"); + int __ordinal2 = __reader.GetOrdinal("Price"); + global::System.Collections.Generic.List __rows = new(); + while (await __reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + __rows.Add(new global::App.Item( + __reader.IsDBNull(__ordinal0) ? throw new global::System.InvalidOperationException("Column 'Id' is NULL but maps to non-nullable 'int'.") : __reader.GetInt32(__ordinal0), + __reader.IsDBNull(__ordinal1) ? throw new global::System.InvalidOperationException("Column 'Name' is NULL but maps to non-nullable 'string'.") : __reader.GetString(__ordinal1), + __reader.IsDBNull(__ordinal2) ? default(decimal?) : __reader.GetDecimal(__ordinal2))); + } + + return __rows; + } + finally + { + await __reader.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + await __command.DisposeAsync().ConfigureAwait(false); + } + } + } + + """; + + var outcome = GeneratorHarness.Run(CanonicalSource); + + var generated = Assert.Single(outcome.GeneratedSources); + Assert.Equal("App.ItemQueries.GetItemsAsync.a9dfc0d7.g.cs", generated.HintName); + Assert.Equal(expected, generated.SourceText.ToString()); + } + + [Fact] + public void Should_ResolveOrdinalsOnceAndGuardNulls_When_RowHasNullableAndNonNullableColumns() + { + var outcome = GeneratorHarness.Run(CanonicalSource); + + var source = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""GetOrdinal("Id")""", source); + Assert.Contains("""GetOrdinal("Name")""", source); + Assert.Contains("""GetOrdinal("Price")""", source); + Assert.Contains("default(decimal?)", source); + Assert.Contains("InvalidOperationException", source); + } + + [Fact] + public void Should_BindEachScalarParameter_When_MethodHasMultipleScalars() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE name = @name AND id > @minimumId")] + public static partial Task> SearchAsync( + DbConnection connection, string name, int minimumId); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("""ParameterName = "@name";""", generated); + Assert.Contains("""ParameterName = "@minimumId";""", generated); + } + + [Fact] + public void Should_UseCancellationTokenNone_When_MethodHasNoCancellationTokenParameter() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.Contains("CancellationToken.None", generated); + } + + [Fact] + public void Should_OmitTransactionAssignment_When_MethodHasNoTransactionParameter() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources).SourceText.ToString(); + Assert.DoesNotContain(".Transaction =", generated); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_ContainingTypesAreNested() + { + const string source = Prelude + """ + public static partial class Data + { + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetAllAsync(DbConnection connection); + } + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + var generated = Assert.Single(outcome.GeneratedSources); + Assert.StartsWith("App.Data.ItemQueries.GetAllAsync.", generated.HintName); + } + + [Fact] + public void Should_EmitCompilableImplementation_When_RowUsesAllSupportedColumnTypes() + { + const string source = Prelude + """ + public sealed record Wide( + int A, long B, short C, byte D, bool E, string F, double G, float H, + decimal I, System.DateTime J, System.Guid K, byte[] L, int? M, string? N); + + public static partial class WideQueries + { + [SqlQuery("SELECT * FROM wide")] + public static partial Task> GetAllAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + AssertCompilesClean(outcome); + } + + private static void AssertCompilesClean(GeneratorRunOutcome outcome) + { + Assert.Empty(outcome.GeneratorDiagnostics); + Assert.DoesNotContain(outcome.CompilationDiagnostics, d => d.Severity >= DiagnosticSeverity.Warning); + } +} diff --git a/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj b/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj new file mode 100644 index 0000000..2ead018 --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + + net10.0 + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs new file mode 100644 index 0000000..b25a9dc --- /dev/null +++ b/test/SqlBound.Generators.UnitTests/SqlQueryGeneratorTests.cs @@ -0,0 +1,195 @@ +namespace SqlBound.Generators.UnitTests; + +public class SqlQueryGeneratorTests +{ + private const string Prelude = """ + using System.Collections.Generic; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + using SqlBound; + + namespace App; + + public sealed record Item(int Id, string Name, decimal? Price); + + """; + + [Fact] + public void Should_GenerateNothing_When_NoMethodCarriesSqlQueryAttribute() + { + const string source = """ + namespace App; + + public static class NoQueries + { + public static int Add(int left, int right) => left + right; + } + """; + + var outcome = GeneratorHarness.Run(source); + + Assert.Empty(outcome.GeneratedSources); + Assert.Empty(outcome.GeneratorDiagnostics); + } + + [Fact] + public void Should_ReportNoDiagnostics_When_MethodIsWellFormed() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, + DbTransaction? transaction, + string category, + CancellationToken cancellationToken = default); + } + """; + + var outcome = GeneratorHarness.Run(source); + + Assert.Empty(outcome.GeneratorDiagnostics); + } + + [Fact] + public void Should_ReportSqlb001_When_MethodIsNotPartial() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static Task> GetItemsAsync(DbConnection connection) + => Task.FromResult>([]); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB001", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb002_When_MethodIsNotStatic() + { + const string source = Prelude + """ + public partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB002", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb003_When_FirstParameterIsNotDbConnection() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync(string category); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB003", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb004_When_ReturnTypeIsNotTaskOfReadOnlyList() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT COUNT(*) FROM items")] + public static partial Task CountItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB004", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb005_When_RowTypeConstructorParameterTypeIsUnsupported() + { + const string source = Prelude + """ + public sealed record Exotic(int Id, object Payload); + + public static partial class ItemQueries + { + [SqlQuery("SELECT id, payload FROM exotics")] + public static partial Task> GetExoticsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB005", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb006_When_QueryParameterTypeIsUnsupported() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items WHERE category = @category")] + public static partial Task> GetItemsAsync( + DbConnection connection, object category); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB006", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb007_When_CommandTextIsWhitespace() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery(" ")] + public static partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB007", diagnostic.Id); + } + + [Fact] + public void Should_ReportSqlb008_When_MethodIsGeneric() + { + const string source = Prelude + """ + public static partial class ItemQueries + { + [SqlQuery("SELECT id, name, price FROM items")] + public static partial Task> GetItemsAsync(DbConnection connection); + } + """; + + var outcome = GeneratorHarness.Run(source); + + var diagnostic = Assert.Single(outcome.GeneratorDiagnostics); + Assert.Equal("SQLB008", diagnostic.Id); + } +} diff --git a/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs new file mode 100644 index 0000000..cf233f1 --- /dev/null +++ b/test/SqlBound.IntegrationTests/GeneratedQueryTests.cs @@ -0,0 +1,46 @@ +using Dapper; +using Microsoft.Data.Sqlite; + +namespace SqlBound.IntegrationTests; + +public class GeneratedQueryTests +{ + [Fact] + public async Task Should_MaterializeRowsIncludingNulls_When_GeneratedQueryExecutes() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + var session = new SqlSession(connection); + await session.RunAsync( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)", + cancellationToken: TestContext.Current.CancellationToken); + await session.RunAsync( + "INSERT INTO items (id, name, price, category) VALUES (1, 'hammer', 9.5, 'tools'), (2, 'nails', NULL, 'tools'), (3, 'apple', 0.5, 'food')", + cancellationToken: TestContext.Current.CancellationToken); + + var items = await ItemQueries.GetByCategoryAsync( + connection, "tools", TestContext.Current.CancellationToken); + + Assert.Equal( + [new Item(1, "hammer", 9.5m), new Item(2, "nails", null)], + items); + } + + [Fact] + public async Task Should_ReadDapperWrites_When_GeneratedQueryRunsOnSharedConnection() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await connection.ExecuteAsync( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)"); + await connection.ExecuteAsync( + "INSERT INTO items (id, name, price, category) VALUES (@Id, @Name, @Price, @Category)", + new { Id = 1, Name = "from-dapper", Price = 1.25, Category = "shared" }); + + var items = await ItemQueries.GetByCategoryAsync( + connection, "shared", TestContext.Current.CancellationToken); + + var item = Assert.Single(items); + Assert.Equal(new Item(1, "from-dapper", 1.25m), item); + } +} diff --git a/test/SqlBound.IntegrationTests/ItemQueries.cs b/test/SqlBound.IntegrationTests/ItemQueries.cs new file mode 100644 index 0000000..0c7de38 --- /dev/null +++ b/test/SqlBound.IntegrationTests/ItemQueries.cs @@ -0,0 +1,12 @@ +using System.Data.Common; + +namespace SqlBound.IntegrationTests; + +public static partial class ItemQueries +{ + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + public static partial Task> GetByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); +} + +public sealed record Item(int Id, string Name, decimal? Price); diff --git a/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj b/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj index 2bf32a9..3c8d34f 100644 --- a/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj +++ b/test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj @@ -11,6 +11,8 @@ + + diff --git a/test/SqlBound.UnitTests/SqlQueryAttributeTests.cs b/test/SqlBound.UnitTests/SqlQueryAttributeTests.cs new file mode 100644 index 0000000..da85075 --- /dev/null +++ b/test/SqlBound.UnitTests/SqlQueryAttributeTests.cs @@ -0,0 +1,35 @@ +namespace SqlBound.UnitTests; + +public class SqlQueryAttributeTests +{ + [Fact] + public void Should_ExposeCommandText_When_Constructed() + { + var attribute = new SqlQueryAttribute("SELECT id FROM items"); + + Assert.Equal("SELECT id FROM items", attribute.CommandText); + } + + [Fact] + public void Should_ThrowArgumentNullException_When_CommandTextIsNull() + { + Assert.Throws("commandText", () => new SqlQueryAttribute(null!)); + } + + [Fact] + public void Should_TargetMethodsOnlyAndDisallowMultiple_When_AttributeUsageInspected() + { + var usage = (AttributeUsageAttribute)Attribute.GetCustomAttribute( + typeof(SqlQueryAttribute), typeof(AttributeUsageAttribute))!; + + Assert.Equal(AttributeTargets.Method, usage.ValidOn); + Assert.False(usage.AllowMultiple); + Assert.False(usage.Inherited); + } + + [Fact] + public void Should_BeSealed_When_TypeInspected() + { + Assert.True(typeof(SqlQueryAttribute).IsSealed); + } +}