From 3d460832bb0d3010c214f0bad9d0597701f6bd6a Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 18:44:14 +0200 Subject: [PATCH 1/8] feat(autocontracts): add typed runtime generator --- .../AutoContractsCacheKeyEmitter.cs | 228 +++++++++ .../AutoContracts/AutoContractsClrTypes.cs | 17 + .../AutoContractsEmissionGuard.cs | 75 +++ .../AutoContracts/AutoContractsEmitter.cs | 406 +++++++++++++++ .../AutoContractsGeneratedSource.cs | 60 +++ .../AutoContractsManifestParser.cs | 466 ++++++++++++++++++ .../AutoContracts/AutoContractsModels.cs | 51 ++ .../AutoContractsSourceGenerator.cs | 33 ++ .../AutoContracts/AutoContractsSourceNames.cs | 11 + .../AutoContracts/AutoContractsSqlEmitter.cs | 244 +++++++++ .../AutoContracts/AutoContractsSqlFacets.cs | 62 +++ SourceGenerators/CaeriusNet.Generator.csproj | 8 +- Src/Abstractions/IRedisCacheManager.cs | 20 +- .../AutoContractGenerateDtoAttribute.cs | 20 + .../AutoContractGenerateProcedureAttribute.cs | 17 + .../AutoContractGenerateTvpAttribute.cs | 17 + .../ICaeriusGeneratedProcedureParameters.cs | 16 + .../StoredProcedureParametersBuilder.Typed.cs | 275 +++++++++++ .../StoredProcedureParametersBuilder.cs | 8 + Src/Caches/RedisCacheManager.cs | 89 +++- Src/CaeriusNet.csproj | 9 +- Src/GlobalUsings.cs | 4 +- Src/Mappers/ICaeriusGeneratedProcedure.cs | 27 + .../StoredProcedureParameters.Typed.cs | 71 +++ .../AutoContractsSourceGeneratorTests.cs | 222 +++++++++ .../GlobalUsings.cs | 1 + .../Utilities/SourceGeneratorTestHelper.cs | 32 ++ ...redProcedureParametersBuilderTypedTests.cs | 277 +++++++++++ .../Caches/FrozenCacheManagerTests.cs | 4 +- .../Caches/RedisCacheManagerTests.cs | 24 +- 30 files changed, 2782 insertions(+), 12 deletions(-) create mode 100644 SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsClrTypes.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsEmissionGuard.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsEmitter.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsManifestParser.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsModels.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsSourceGenerator.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsSourceNames.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs create mode 100644 SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs create mode 100644 Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs create mode 100644 Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs create mode 100644 Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs create mode 100644 Src/Builders/ICaeriusGeneratedProcedureParameters.cs create mode 100644 Src/Builders/StoredProcedureParametersBuilder.Typed.cs create mode 100644 Src/Mappers/ICaeriusGeneratedProcedure.cs create mode 100644 Src/Mappers/StoredProcedureParameters.Typed.cs create mode 100644 Tests/CaeriusNet.Generator.Tests/AutoContracts/AutoContractsSourceGeneratorTests.cs create mode 100644 Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs diff --git a/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs b/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs new file mode 100644 index 0000000..2c176d2 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs @@ -0,0 +1,228 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsCacheKeyEmitter +{ + internal static void Append( + StringBuilder sb, + AutoContractsProcedure procedure, + IReadOnlyDictionary tableTypesBySqlName) + { + sb.Append("public static partial class ") + .Append(procedure.ClrName) + .AppendLine("CacheKeys"); + sb.AppendLine("{"); + sb.Append(" public static string ByParameters(") + .Append(procedure.ParametersClrName) + .AppendLine(" parameters)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(parameters);"); + sb.AppendLine(" using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);"); + sb.Append(" AppendString(hash, ") + .Append(procedure.ClrName) + .AppendLine(".FullName);"); + sb.Append(" AppendString(hash, ") + .Append(procedure.ClrName) + .AppendLine(".ContractHash);"); + + foreach (var parameter in procedure.Parameters) + AppendParameter(sb, parameter, tableTypesBySqlName); + + sb.Append(" return ") + .Append(procedure.ClrName) + .AppendLine(".FullName + \":\" + Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();"); + sb.AppendLine(" }"); + sb.AppendLine(); + AppendHelpers(sb); + sb.AppendLine("}"); + } + + private static void AppendParameter( + StringBuilder sb, + AutoContractsParameter parameter, + IReadOnlyDictionary tableTypesBySqlName) + { + sb.Append(" AppendString(hash, ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(parameter.Name)) + .AppendLine(");"); + + var property = "parameters." + AutoContractsSqlEmitter.ToIdentifier(parameter.Name); + if (parameter.IsTableType && tableTypesBySqlName.TryGetValue(parameter.SqlType, out var tableType)) + AppendTvpValue(sb, property, tableType); + else + AppendValue(sb, property, parameter.ClrType, parameter.Nullable, 8); + } + + private static void AppendTvpValue(StringBuilder sb, string property, AutoContractsTableType tableType) + { + sb.Append(" AppendInt32(hash, ") + .Append(property) + .AppendLine(".Length);"); + sb.Append(" foreach (var row in ") + .Append(property) + .AppendLine(".Span)"); + sb.AppendLine(" {"); + foreach (var column in tableType.Columns) + { + var value = "row." + AutoContractsSqlEmitter.ToIdentifier(column.Name); + AppendValue(sb, value, column.ClrType, column.Nullable, 12); + } + + sb.AppendLine(" }"); + } + + private static void AppendValue( + StringBuilder sb, + string expression, + string clrType, + bool nullable, + int indent) + { + var normalizedClrType = clrType.TrimEnd('?'); + var pad = new string(' ', indent); + + if (nullable && AutoContractsClrTypes.IsNullableValueType(normalizedClrType)) + { + sb.Append(pad).Append("if (").Append(expression).AppendLine(".HasValue)"); + sb.Append(pad).AppendLine("{"); + sb.Append(pad).AppendLine(" AppendByte(hash, 1);"); + AppendValue(sb, expression + ".Value", normalizedClrType, false, indent + 4); + sb.Append(pad).AppendLine("}"); + sb.Append(pad).AppendLine("else"); + sb.Append(pad).AppendLine("{"); + sb.Append(pad).AppendLine(" AppendByte(hash, 0);"); + sb.Append(pad).AppendLine("}"); + return; + } + + var method = GetAppendMethod(normalizedClrType); + + sb.Append(pad) + .Append(method) + .Append("(hash, "); + + if (method == "AppendString" && normalizedClrType != "string") + sb.Append(expression).Append(".ToString()"); + else + sb.Append(expression); + + sb.AppendLine(");"); + } + + private static string GetAppendMethod(string clrType) + { + return clrType switch + { + "bool" => "AppendBoolean", + "byte" => "AppendByte", + "short" => "AppendInt16", + "int" => "AppendInt32", + "long" => "AppendInt64", + "float" => "AppendSingle", + "double" => "AppendDouble", + "decimal" => "AppendDecimal", + "DateOnly" => "AppendDateOnly", + "TimeOnly" => "AppendTimeOnly", + "DateTime" => "AppendDateTime", + "DateTimeOffset" => "AppendDateTimeOffset", + "Guid" => "AppendGuid", + "string" => "AppendString", + "byte[]" => "AppendBytes", + _ => "AppendString" + }; + } + + private static void AppendHelpers(StringBuilder sb) + { + sb.AppendLine( + " private static void AppendBoolean(IncrementalHash hash, bool value) => AppendByte(hash, value ? (byte)1 : (byte)0);"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendByte(IncrementalHash hash, byte value)"); + sb.AppendLine(" {"); + sb.AppendLine(" Span buffer = stackalloc byte[1];"); + sb.AppendLine(" buffer[0] = value;"); + sb.AppendLine(" hash.AppendData(buffer);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendInt16(IncrementalHash hash, short value)"); + sb.AppendLine(" {"); + sb.AppendLine(" Span buffer = stackalloc byte[2];"); + sb.AppendLine(" BinaryPrimitives.WriteInt16LittleEndian(buffer, value);"); + sb.AppendLine(" hash.AppendData(buffer);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendInt32(IncrementalHash hash, int value)"); + sb.AppendLine(" {"); + sb.AppendLine(" Span buffer = stackalloc byte[4];"); + sb.AppendLine(" BinaryPrimitives.WriteInt32LittleEndian(buffer, value);"); + sb.AppendLine(" hash.AppendData(buffer);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendInt64(IncrementalHash hash, long value)"); + sb.AppendLine(" {"); + sb.AppendLine(" Span buffer = stackalloc byte[8];"); + sb.AppendLine(" BinaryPrimitives.WriteInt64LittleEndian(buffer, value);"); + sb.AppendLine(" hash.AppendData(buffer);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine( + " private static void AppendSingle(IncrementalHash hash, float value) => AppendInt32(hash, BitConverter.SingleToInt32Bits(value));"); + sb.AppendLine(); + sb.AppendLine( + " private static void AppendDouble(IncrementalHash hash, double value) => AppendInt64(hash, BitConverter.DoubleToInt64Bits(value));"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendDecimal(IncrementalHash hash, decimal value)"); + sb.AppendLine(" {"); + sb.AppendLine(" foreach (var part in decimal.GetBits(value))"); + sb.AppendLine(" AppendInt32(hash, part);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine( + " private static void AppendDateOnly(IncrementalHash hash, DateOnly value) => AppendInt32(hash, value.DayNumber);"); + sb.AppendLine(); + sb.AppendLine( + " private static void AppendTimeOnly(IncrementalHash hash, TimeOnly value) => AppendInt64(hash, value.Ticks);"); + sb.AppendLine(); + sb.AppendLine( + " private static void AppendDateTime(IncrementalHash hash, DateTime value) => AppendInt64(hash, value.ToBinary());"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendDateTimeOffset(IncrementalHash hash, DateTimeOffset value)"); + sb.AppendLine(" {"); + sb.AppendLine(" AppendInt64(hash, value.UtcDateTime.ToBinary());"); + sb.AppendLine(" AppendInt64(hash, value.Offset.Ticks);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendGuid(IncrementalHash hash, Guid value)"); + sb.AppendLine(" {"); + sb.AppendLine(" Span buffer = stackalloc byte[16];"); + sb.AppendLine(" value.TryWriteBytes(buffer);"); + sb.AppendLine(" hash.AppendData(buffer);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendString(IncrementalHash hash, string? value)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (value is null)"); + sb.AppendLine(" {"); + sb.AppendLine(" AppendByte(hash, 0);"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" AppendByte(hash, 1);"); + sb.AppendLine(" var bytes = Encoding.UTF8.GetBytes(value);"); + sb.AppendLine(" AppendInt32(hash, bytes.Length);"); + sb.AppendLine(" hash.AppendData(bytes);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private static void AppendBytes(IncrementalHash hash, byte[]? value)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (value is null)"); + sb.AppendLine(" {"); + sb.AppendLine(" AppendByte(hash, 0);"); + sb.AppendLine(" return;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" AppendByte(hash, 1);"); + sb.AppendLine(" AppendInt32(hash, value.Length);"); + sb.AppendLine(" hash.AppendData(value);"); + sb.AppendLine(" }"); + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs b/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs new file mode 100644 index 0000000..d96ab8a --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs @@ -0,0 +1,17 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsClrTypes +{ + internal static bool IsNullableValueType(string clrType) + { + var normalized = clrType.TrimEnd('?'); + return normalized is "bool" or "byte" or "short" or "int" or "long" or "float" or "double" or "decimal" or + "DateOnly" or "TimeOnly" or "DateTime" or "DateTimeOffset" or "Guid"; + } + + internal static bool IsReferenceType(string clrType) + { + var normalized = clrType.TrimEnd('?'); + return normalized is "string" or "byte[]" or "object" || normalized.Contains("<"); + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsEmissionGuard.cs b/SourceGenerators/AutoContracts/AutoContractsEmissionGuard.cs new file mode 100644 index 0000000..680c7e3 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsEmissionGuard.cs @@ -0,0 +1,75 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsEmissionGuard +{ + internal static bool CanEmit(AutoContractsManifest manifest) + { + var valid = true; + var tableTypes = BuildTableTypeLookup(manifest); + + foreach (var tableType in manifest.TableTypes) + valid &= CanEmitTableType(tableType); + + foreach (var procedure in manifest.Procedures) + valid &= CanEmitProcedure(procedure, tableTypes); + + return valid; + } + + private static HashSet BuildTableTypeLookup(AutoContractsManifest manifest) + { + var tableTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var tableType in manifest.TableTypes) + tableTypes.Add(BuildFullName(tableType.Schema, tableType.Name)); + + return tableTypes; + } + + private static bool CanEmitTableType(AutoContractsTableType tableType) + { + var valid = true; + foreach (var column in tableType.Columns) + { + if (AutoContractsSqlEmitter.IsSupportedSqlType(column.SqlType)) + continue; + + valid = false; + } + + return valid; + } + + private static bool CanEmitProcedure( + AutoContractsProcedure procedure, + HashSet tableTypes) + { + var valid = true; + + foreach (var parameter in procedure.Parameters) + valid &= CanEmitParameter(parameter, tableTypes); + + foreach (var column in procedure.ResultSet.Columns) + valid &= AutoContractsSqlEmitter.IsSupportedSqlType(column.SqlType); + + valid &= !string.Equals(procedure.ResultSet.Status, "Undetermined", StringComparison.OrdinalIgnoreCase); + return valid; + } + + private static bool CanEmitParameter( + AutoContractsParameter parameter, + HashSet tableTypes) + { + if (parameter.IsOutput) + return false; + + if (parameter.IsTableType) + return tableTypes.Contains(parameter.SqlType); + + return AutoContractsSqlEmitter.IsSupportedSqlType(parameter.SqlType); + } + + private static string BuildFullName(string schema, string name) + { + return schema + "." + name; + } +} diff --git a/SourceGenerators/AutoContracts/AutoContractsEmitter.cs b/SourceGenerators/AutoContracts/AutoContractsEmitter.cs new file mode 100644 index 0000000..25ce336 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsEmitter.cs @@ -0,0 +1,406 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsEmitter +{ + internal static string Emit(AutoContractsManifest manifest) + { + var sb = new StringBuilder(4096); + + AutoContractsGeneratedSource.AppendPreamble(sb, manifest.Namespace); + var tableTypesBySqlName = manifest.TableTypes.ToDictionary( + static tableType => tableType.Schema + "." + tableType.Name, + static tableType => tableType, + StringComparer.OrdinalIgnoreCase); + + foreach (var tableType in manifest.TableTypes) + { + AppendTableType(sb, tableType); + sb.AppendLine(); + } + + foreach (var procedure in manifest.Procedures) + { + AppendProcedure(sb, procedure, tableTypesBySqlName); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void AppendTableType(StringBuilder sb, AutoContractsTableType tableType) + { + sb.Append("[AutoContractGenerateTvp(Schema = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.Schema)) + .Append(", TypeName = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.Name)) + .Append(", ContractHash = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.ContractHash)) + .AppendLine(")]"); + + sb.Append("public readonly partial record struct ") + .Append(tableType.ClrName) + .Append('('); + + for (var i = 0; i < tableType.Columns.Count; i++) + { + var column = tableType.Columns[i]; + if (i > 0) + sb.Append(", "); + + sb.Append(AutoContractsSqlEmitter.ToNullableClrType(column)) + .Append(' ') + .Append(AutoContractsSqlEmitter.ToIdentifier(column.Name)); + } + + sb.AppendLine(")"); + sb.AppendLine("{"); + sb.Append(" public const string SchemaName = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.Schema)).AppendLine(";"); + sb.Append(" public const string SqlTypeName = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.Name)).AppendLine(";"); + sb.Append(" public const string FullSqlTypeName = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.Schema + "." + tableType.Name)).AppendLine(";"); + sb.Append(" public const string ContractHash = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(tableType.ContractHash)).AppendLine(";"); + sb.AppendLine(); + + sb.AppendLine(" private static readonly SqlMetaData[] _metadata ="); + sb.AppendLine(" ["); + for (var i = 0; i < tableType.Columns.Count; i++) + { + var trailing = i + 1 < tableType.Columns.Count ? "," : string.Empty; + sb.Append(" ") + .Append(AutoContractsSqlEmitter.BuildSqlMetaDataExpression(tableType.Columns[i])) + .AppendLine(trailing); + } + + sb.AppendLine(" ];"); + sb.AppendLine(); + sb.AppendLine(" public static SqlMetaData[] Metadata => _metadata;"); + sb.AppendLine(); + + sb.AppendLine(" [MethodImpl(MethodImplOptions.AggressiveInlining)]"); + AutoContractsGeneratedSource.AppendGeneratedCodeAttribute(sb, " "); + sb.Append(" public static void MapRow(SqlDataRecord record, ") + .Append(tableType.ClrName) + .AppendLine(" row)"); + sb.AppendLine(" {"); + for (var i = 0; i < tableType.Columns.Count; i++) + { + var column = tableType.Columns[i]; + var property = "row." + AutoContractsSqlEmitter.ToIdentifier(column.Name); + var setExpression = AutoContractsSqlEmitter.BuildSetExpression(column, i, property); + if (column.Nullable) + { + sb.Append(" if (").Append(property).AppendLine(" is null)"); + sb.Append(" record.SetDBNull(").Append(i).AppendLine(");"); + sb.AppendLine(" else"); + sb.Append(" ").Append(setExpression).AppendLine(";"); + } + else + { + sb.Append(" ").Append(setExpression).AppendLine(";"); + } + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + private static void AppendProcedure( + StringBuilder sb, + AutoContractsProcedure procedure, + Dictionary tableTypesBySqlName) + { + AppendProcedureDescriptor(sb, procedure); + sb.AppendLine(); + AppendParametersRecord(sb, procedure); + sb.AppendLine(); + + if (IsAvailable(procedure.ResultSet) && !string.IsNullOrEmpty(procedure.ResultClrName)) + { + AppendResultRecord(sb, procedure); + sb.AppendLine(); + } + + AppendBuilderExtensions(sb, procedure); + sb.AppendLine(); + AutoContractsCacheKeyEmitter.Append(sb, procedure, tableTypesBySqlName); + + if (IsAvailable(procedure.ResultSet) && !string.IsNullOrEmpty(procedure.ResultClrName)) + { + sb.AppendLine(); + AppendExecutionExtensions(sb, procedure); + } + } + + private static void AppendProcedureDescriptor(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append("[AutoContractGenerateProcedure(Schema = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Schema)) + .Append(", Name = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Name)) + .Append(", ContractHash = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.ContractHash)) + .AppendLine(")]"); + + sb.Append("public readonly partial struct ") + .Append(procedure.ClrName) + .Append(" : ICaeriusGeneratedProcedure<") + .Append(procedure.ClrName) + .AppendLine(">"); + sb.AppendLine("{"); + sb.Append(" public static string SchemaName => ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Schema)).AppendLine(";"); + sb.Append(" public static string ProcedureName => ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Name)).AppendLine(";"); + sb.Append(" public static string FullName => ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Schema + "." + procedure.Name)).AppendLine(";"); + sb.Append(" public static string ContractHash => ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.ContractHash)).AppendLine(";"); + sb.Append(" public static int ParameterCount => ") + .Append(procedure.Parameters.Count).AppendLine(";"); + sb.Append(" public static int ResultSetCount => ") + .Append(IsAvailable(procedure.ResultSet) ? 1 : 0).AppendLine(";"); + sb.AppendLine("}"); + } + + private static void AppendParametersRecord(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append("public sealed partial record ") + .Append(procedure.ParametersClrName) + .Append('('); + + for (var i = 0; i < procedure.Parameters.Count; i++) + { + var parameter = procedure.Parameters[i]; + if (i > 0) + sb.Append(", "); + + sb.Append(parameter.ClrType) + .Append(' ') + .Append(AutoContractsSqlEmitter.ToIdentifier(parameter.Name)); + } + + sb.Append(") : ICaeriusGeneratedProcedureParameters<") + .Append(procedure.ClrName) + .Append(", ") + .Append(procedure.ParametersClrName) + .AppendLine(">"); + sb.AppendLine("{"); + sb.Append(" public static void Bind(StoredProcedureParametersBuilder<") + .Append(procedure.ClrName) + .Append("> builder, ") + .Append(procedure.ParametersClrName) + .AppendLine(" parameters)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(builder);"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(parameters);"); + sb.AppendLine(" builder.WithParameters(parameters);"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + private static void AppendResultRecord(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append("[AutoContractGenerateDto(Schema = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Schema)) + .Append(", Procedure = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.Name)) + .Append(", ResultSetIndex = 0, ContractHash = ") + .Append(AutoContractsSqlEmitter.ToStringLiteral(procedure.ContractHash)) + .AppendLine(")]"); + + sb.Append("public sealed partial record ") + .Append(procedure.ResultClrName) + .Append('('); + + for (var i = 0; i < procedure.ResultSet.Columns.Count; i++) + { + var column = procedure.ResultSet.Columns[i]; + if (i > 0) + sb.Append(", "); + + sb.Append(AutoContractsSqlEmitter.ToNullableClrType(column)) + .Append(' ') + .Append(AutoContractsSqlEmitter.ToIdentifier(column.Name)); + } + + sb.Append(") : ISpMapper<").Append(procedure.ResultClrName).AppendLine(">"); + sb.AppendLine("{"); + sb.AppendLine(" [MethodImpl(MethodImplOptions.AggressiveInlining)]"); + AutoContractsGeneratedSource.AppendGeneratedCodeAttribute(sb, " "); + sb.Append(" public static ").Append(procedure.ResultClrName) + .AppendLine(" MapFromDataReader(SqlDataReader reader)"); + sb.AppendLine(" {"); + sb.Append(" return new ").Append(procedure.ResultClrName).AppendLine("("); + for (var i = 0; i < procedure.ResultSet.Columns.Count; i++) + { + var trailing = i + 1 < procedure.ResultSet.Columns.Count ? "," : string.Empty; + sb.Append(" ") + .Append(AutoContractsSqlEmitter.BuildReaderExpression(procedure.ResultSet.Columns[i], i)) + .AppendLine(trailing); + } + + sb.AppendLine(" );"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + private static void AppendBuilderExtensions(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append("public static partial class ") + .Append(procedure.ClrName) + .AppendLine("BuilderExtensions"); + sb.AppendLine("{"); + + sb.Append(" public static StoredProcedureParametersBuilder<") + .Append(procedure.ClrName) + .Append("> WithParameters(this StoredProcedureParametersBuilder<") + .Append(procedure.ClrName) + .Append("> builder, ") + .Append(procedure.ParametersClrName) + .AppendLine(" parameters)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(builder);"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(parameters);"); + + foreach (var parameter in procedure.Parameters) + { + var property = "parameters." + AutoContractsSqlEmitter.ToIdentifier(parameter.Name); + if (parameter.IsTableType) + AppendTvpParameterBinding(sb, parameter, property); + else + AppendScalarParameterBinding(sb, parameter, property); + } + + sb.AppendLine(" return builder.MarkGeneratedParametersBound();"); + sb.AppendLine(" }"); + + if (procedure.Parameters.Count > 0) + { + sb.AppendLine(); + AppendBuilderValuesOverload(sb, procedure); + } + + sb.AppendLine("}"); + } + + private static void AppendBuilderValuesOverload(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append(" public static StoredProcedureParametersBuilder<") + .Append(procedure.ClrName) + .Append("> WithParameters(this StoredProcedureParametersBuilder<") + .Append(procedure.ClrName) + .AppendLine("> builder,"); + + for (var i = 0; i < procedure.Parameters.Count; i++) + { + var parameter = procedure.Parameters[i]; + var trailing = i + 1 < procedure.Parameters.Count ? "," : string.Empty; + sb.Append(" ") + .Append(parameter.ClrType) + .Append(' ') + .Append(AutoContractsSqlEmitter.ToIdentifier(parameter.Name)) + .AppendLine(trailing); + } + + sb.AppendLine(" )"); + sb.AppendLine(" {"); + sb.Append(" return builder.WithParameters(new ") + .Append(procedure.ParametersClrName) + .Append('('); + + for (var i = 0; i < procedure.Parameters.Count; i++) + { + if (i > 0) + sb.Append(", "); + + sb.Append(AutoContractsSqlEmitter.ToIdentifier(procedure.Parameters[i].Name)); + } + + sb.AppendLine("));"); + sb.AppendLine(" }"); + } + + private static void AppendScalarParameterBinding( + StringBuilder sb, + AutoContractsParameter parameter, + string property) + { + sb.Append(" builder.AddGeneratedParameter(") + .Append(parameter.Ordinal) + .Append(", ") + .Append(AutoContractsSqlEmitter.ToStringLiteral("@" + parameter.Name.TrimStart('@'))) + .Append(", ") + .Append(property) + .Append(", ") + .Append(AutoContractsSqlEmitter.BuildSqlDbTypeExpression(parameter.SqlType)); + + var facets = AutoContractsSqlFacets.BuildParameterArguments(parameter); + if (!string.IsNullOrEmpty(facets)) + sb.Append(", ").Append(facets); + + sb.AppendLine(");"); + } + + private static void AppendTvpParameterBinding( + StringBuilder sb, + AutoContractsParameter parameter, + string property) + { + var rowType = ExtractTvpRowType(parameter.ClrType); + sb.Append(" builder.AddGeneratedTvpParameter<") + .Append(rowType) + .Append(">(") + .Append(parameter.Ordinal) + .Append(", ") + .Append(AutoContractsSqlEmitter.ToStringLiteral("@" + parameter.Name.TrimStart('@'))) + .Append(", ") + .Append(rowType) + .Append(".FullSqlTypeName, ") + .Append(rowType) + .Append(".Metadata, ") + .Append(property) + .Append(", ") + .Append(rowType) + .Append(".MapRow") + .AppendLine(");"); + } + + private static void AppendExecutionExtensions(StringBuilder sb, AutoContractsProcedure procedure) + { + sb.Append("public static partial class ") + .Append(procedure.ClrName) + .AppendLine("ExecutionExtensions"); + sb.AppendLine("{"); + sb.Append(" public static ValueTask> QueryAsImmutableArrayAsync(this ICaeriusNetDbContext dbContext, StoredProcedureParameters<") + .Append(procedure.ClrName) + .AppendLine("> parameters, CancellationToken cancellationToken = default)"); + sb.AppendLine(" {"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(dbContext);"); + sb.AppendLine(" ArgumentNullException.ThrowIfNull(parameters);"); + sb.Append(" return dbContext.QueryAsImmutableArrayAsync<") + .Append(procedure.ResultClrName) + .AppendLine(">(parameters.AsUntyped(), cancellationToken);"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + } + + private static bool IsAvailable(AutoContractsResultSet resultSet) + { + return string.Equals(resultSet.Status, "Available", StringComparison.OrdinalIgnoreCase) + && resultSet.Columns.Count > 0; + } + + private static string ExtractTvpRowType(string clrType) + { + const string Prefix = "ReadOnlyMemory<"; + if (clrType.StartsWith(Prefix, StringComparison.Ordinal) && clrType.EndsWith(">", StringComparison.Ordinal)) + return clrType.Substring(Prefix.Length, clrType.Length - Prefix.Length - 1); + + return clrType; + } +} diff --git a/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs b/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs new file mode 100644 index 0000000..a16312c --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs @@ -0,0 +1,60 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsGeneratedSource +{ + internal static void AppendPreamble(StringBuilder sb, string @namespace) + { + AppendHeader(sb); + AppendUsings(sb); + AppendNamespace(sb, @namespace); + } + + internal static void AppendGeneratedCodeAttribute(StringBuilder sb, string indent) + { + sb.Append(indent) + .Append("[GeneratedCode(\"") + .Append(GeneratedCodeInfo.ToolName) + .Append("\", \"") + .Append(GeneratedCodeInfo.Version) + .AppendLine("\")]"); + } + + private static void AppendHeader(StringBuilder sb) + { + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member"); + sb.AppendLine(); + } + + private static void AppendUsings(StringBuilder sb) + { + sb.AppendLine("using System;"); + sb.AppendLine("using System.Buffers.Binary;"); + sb.AppendLine("using System.CodeDom.Compiler;"); + sb.AppendLine("using System.Collections.Immutable;"); + sb.AppendLine("using System.Data;"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using System.Security.Cryptography;"); + sb.AppendLine("using System.Text;"); + sb.AppendLine("using System.Threading;"); + sb.AppendLine("using System.Threading.Tasks;"); + sb.AppendLine("using CaeriusNet.Abstractions;"); + sb.AppendLine("using CaeriusNet.Attributes.AutoContracts;"); + sb.AppendLine("using CaeriusNet.Builders;"); + sb.AppendLine("using CaeriusNet.Commands.Reads;"); + sb.AppendLine("using CaeriusNet.Mappers;"); + sb.AppendLine("using Microsoft.Data.SqlClient;"); + sb.AppendLine("using Microsoft.Data.SqlClient.Server;"); + sb.AppendLine(); + } + + private static void AppendNamespace(StringBuilder sb, string ns) + { + if (string.IsNullOrWhiteSpace(ns)) + return; + + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs b/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs new file mode 100644 index 0000000..5832bf4 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs @@ -0,0 +1,466 @@ +using System.Globalization; + +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsManifestParser +{ + private const string DefaultNamespace = "CaeriusNet.Generated"; + + internal static AutoContractsManifest? ParseOrDefault(AdditionalText text, CancellationToken cancellationToken) + { + var source = text.GetText(cancellationToken)?.ToString(); + if (string.IsNullOrWhiteSpace(source)) + return null; + + try + { + var json = SimpleJsonParser.Parse(source!); + var root = json.AsObject(); + var version = root.GetInt32("version", 1); + var ns = root.GetString("namespace", DefaultNamespace); + var tableTypes = ParseTableTypes(root.GetArray("tableTypes")); + var procedures = ParseProcedures(root.GetArray("procedures")); + + return new AutoContractsManifest(version, ns, tableTypes, procedures); + } + catch + { + return null; + } + } + + private static EquatableArray ParseTableTypes(IReadOnlyList values) + { + var tableTypes = new AutoContractsTableType[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i].AsObject(); + tableTypes[i] = new AutoContractsTableType( + value.GetRequiredString("schema"), + value.GetRequiredString("name"), + value.GetRequiredString("clrName"), + ParseColumns(value.GetArray("columns")), + value.GetString("contractHash", string.Empty)); + } + + return new EquatableArray(tableTypes.ToImmutableArray()); + } + + private static EquatableArray ParseProcedures(IReadOnlyList values) + { + var procedures = new AutoContractsProcedure[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i].AsObject(); + var resultSet = value.TryGetObject("resultSet", out var resultSetObject) + ? ParseResultSet(resultSetObject) + : new AutoContractsResultSet( + "None", + EquatableArray.Empty, + null); + + procedures[i] = new AutoContractsProcedure( + value.GetRequiredString("schema"), + value.GetRequiredString("name"), + value.GetRequiredString("clrName"), + value.GetRequiredString("parametersClrName"), + value.GetString("resultClrName", null), + ParseParameters(value.GetArray("parameters")), + resultSet, + value.GetString("contractHash", string.Empty)); + } + + return new EquatableArray(procedures.ToImmutableArray()); + } + + private static AutoContractsResultSet ParseResultSet(JsonObject value) + { + return new AutoContractsResultSet( + value.GetString("status", "None"), + ParseColumns(value.GetArray("columns")), + value.GetString("errorMessage", null)); + } + + private static EquatableArray ParseParameters(IReadOnlyList values) + { + var parameters = new AutoContractsParameter[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i].AsObject(); + parameters[i] = new AutoContractsParameter( + value.GetInt32("ordinal", i + 1), + value.GetRequiredString("name"), + value.GetRequiredString("sqlType"), + value.GetRequiredString("clrType"), + value.GetBoolean("isTableType", false), + value.GetBoolean("isOutput", false), + value.GetBoolean("nullable", false), + value.GetNullableInt32("maxLength"), + value.GetNullableByte("precision"), + value.GetNullableByte("scale")); + } + + return new EquatableArray(parameters.ToImmutableArray()); + } + + private static EquatableArray ParseColumns(IReadOnlyList values) + { + var columns = new AutoContractsColumn[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i].AsObject(); + columns[i] = new AutoContractsColumn( + value.GetInt32("ordinal", i + 1), + value.GetRequiredString("name"), + value.GetRequiredString("sqlType"), + value.GetRequiredString("clrType"), + value.GetBoolean("nullable", false), + value.GetNullableInt32("maxLength"), + value.GetNullableByte("precision"), + value.GetNullableByte("scale")); + } + + return new EquatableArray(columns.ToImmutableArray()); + } + + private sealed class SimpleJsonParser(string text) + { + private int _position; + + private bool IsEnd => _position >= text.Length; + + internal static JsonValue Parse(string text) + { + var parser = new SimpleJsonParser(text); + var value = parser.ParseValue(); + parser.SkipWhiteSpace(); + if (!parser.IsEnd) + throw new FormatException("Unexpected trailing content in contract manifest."); + + return value; + } + + private JsonValue ParseValue() + { + SkipWhiteSpace(); + if (IsEnd) + throw new FormatException("Unexpected end of JSON."); + + return text[_position] switch + { + '{' => ParseObject(), + '[' => ParseArray(), + '"' => new JsonValue(ParseString()), + 't' => ParseLiteral("true", new JsonValue(true)), + 'f' => ParseLiteral("false", new JsonValue(false)), + 'n' => ParseLiteral("null", JsonValue.Null), + '-' or >= '0' and <= '9' => new JsonValue(ParseNumber()), + _ => throw new FormatException($"Unexpected character '{text[_position]}' in JSON.") + }; + } + + private JsonValue ParseObject() + { + Expect('{'); + var values = new Dictionary(StringComparer.Ordinal); + SkipWhiteSpace(); + if (TryConsume('}')) + return new JsonValue(new JsonObject(values)); + + while (true) + { + SkipWhiteSpace(); + var name = ParseString(); + SkipWhiteSpace(); + Expect(':'); + values[name] = ParseValue(); + SkipWhiteSpace(); + if (TryConsume('}')) + return new JsonValue(new JsonObject(values)); + Expect(','); + } + } + + private JsonValue ParseArray() + { + Expect('['); + var values = new List(); + SkipWhiteSpace(); + if (TryConsume(']')) + return new JsonValue(values); + + while (true) + { + values.Add(ParseValue()); + SkipWhiteSpace(); + if (TryConsume(']')) + return new JsonValue(values); + Expect(','); + } + } + + private string ParseString() + { + Expect('"'); + var sb = new StringBuilder(); + while (!IsEnd) + { + var ch = text[_position++]; + if (ch == '"') + return sb.ToString(); + + if (ch != '\\') + { + sb.Append(ch); + continue; + } + + if (IsEnd) + throw new FormatException("Unterminated JSON escape sequence."); + + var escaped = text[_position++]; + switch (escaped) + { + case '"': + case '\\': + case '/': + sb.Append(escaped); + break; + case 'b': + sb.Append('\b'); + break; + case 'f': + sb.Append('\f'); + break; + case 'n': + sb.Append('\n'); + break; + case 'r': + sb.Append('\r'); + break; + case 't': + sb.Append('\t'); + break; + case 'u': + sb.Append(ParseUnicodeEscape()); + break; + default: + throw new FormatException($"Unsupported JSON escape sequence '\\{escaped}'."); + } + } + + throw new FormatException("Unterminated JSON string."); + } + + private char ParseUnicodeEscape() + { + if (_position + 4 > text.Length) + throw new FormatException("Invalid JSON unicode escape."); + + var value = Convert.ToInt32(text.Substring(_position, 4), 16); + _position += 4; + return (char)value; + } + + private double ParseNumber() + { + var start = _position; + if (text[_position] == '-') + _position++; + + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + + if (!IsEnd && text[_position] == '.') + { + _position++; + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + } + + if (!IsEnd && (text[_position] == 'e' || text[_position] == 'E')) + { + _position++; + if (!IsEnd && (text[_position] == '+' || text[_position] == '-')) + _position++; + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + } + + return double.Parse( + text.Substring(start, _position - start), + CultureInfo.InvariantCulture); + } + + private JsonValue ParseLiteral(string literal, JsonValue value) + { + if (_position + literal.Length > text.Length || + !string.Equals( + text.Substring(_position, literal.Length), + literal, + StringComparison.Ordinal)) + throw new FormatException($"Invalid JSON literal at position {_position}."); + + _position += literal.Length; + return value; + } + + private void SkipWhiteSpace() + { + while (!IsEnd && char.IsWhiteSpace(text[_position])) + _position++; + } + + private bool TryConsume(char expected) + { + if (IsEnd || text[_position] != expected) + return false; + + _position++; + return true; + } + + private void Expect(char expected) + { + if (!TryConsume(expected)) + throw new FormatException($"Expected '{expected}' at position {_position}."); + } + } + + private sealed class JsonValue + { + internal static readonly JsonValue Null = new(); + + private readonly object? _value; + + private JsonValue() + { + } + + internal JsonValue(string value) + { + _value = value; + } + + internal JsonValue(double value) + { + _value = value; + } + + internal JsonValue(bool value) + { + _value = value; + } + + internal JsonValue(JsonObject value) + { + _value = value; + } + + internal JsonValue(IReadOnlyList value) + { + _value = value; + } + + internal bool IsNull => _value is null; + + internal JsonObject AsObject() + { + return _value as JsonObject + ?? throw new FormatException("Expected JSON object."); + } + + internal IReadOnlyList AsArray() + { + return _value as IReadOnlyList + ?? throw new FormatException("Expected JSON array."); + } + + internal string AsString() + { + return _value as string + ?? throw new FormatException("Expected JSON string."); + } + + internal int AsInt32() + { + return Convert.ToInt32(_value, CultureInfo.InvariantCulture); + } + + internal byte AsByte() + { + return Convert.ToByte(_value, CultureInfo.InvariantCulture); + } + + internal bool AsBoolean() + { + return _value is bool value + ? value + : throw new FormatException("Expected JSON boolean."); + } + } + + private sealed class JsonObject(Dictionary values) + { + internal string GetRequiredString(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsString() + : throw new FormatException($"Required manifest property '{name}' is missing."); + } + + internal string GetString(string name, string? defaultValue) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsString() + : defaultValue ?? string.Empty; + } + + internal int GetInt32(string name, int defaultValue) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsInt32() + : defaultValue; + } + + internal int? GetNullableInt32(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsInt32() + : null; + } + + internal byte? GetNullableByte(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsByte() + : null; + } + + internal bool GetBoolean(string name, bool defaultValue) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsBoolean() + : defaultValue; + } + + internal IReadOnlyList GetArray(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsArray() + : []; + } + + internal bool TryGetObject(string name, out JsonObject obj) + { + if (values.TryGetValue(name, out var value) && !value.IsNull) + { + obj = value.AsObject(); + return true; + } + + obj = null!; + return false; + } + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsModels.cs b/SourceGenerators/AutoContracts/AutoContractsModels.cs new file mode 100644 index 0000000..4e19482 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsModels.cs @@ -0,0 +1,51 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal sealed record AutoContractsManifest( + int Version, + string Namespace, + EquatableArray TableTypes, + EquatableArray Procedures); + +internal sealed record AutoContractsTableType( + string Schema, + string Name, + string ClrName, + EquatableArray Columns, + string ContractHash); + +internal sealed record AutoContractsProcedure( + string Schema, + string Name, + string ClrName, + string ParametersClrName, + string? ResultClrName, + EquatableArray Parameters, + AutoContractsResultSet ResultSet, + string ContractHash); + +internal sealed record AutoContractsParameter( + int Ordinal, + string Name, + string SqlType, + string ClrType, + bool IsTableType, + bool IsOutput, + bool Nullable, + int? MaxLength, + byte? Precision, + byte? Scale); + +internal sealed record AutoContractsResultSet( + string Status, + EquatableArray Columns, + string? ErrorMessage); + +internal sealed record AutoContractsColumn( + int Ordinal, + string Name, + string SqlType, + string ClrType, + bool Nullable, + int? MaxLength, + byte? Precision, + byte? Scale); \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsSourceGenerator.cs b/SourceGenerators/AutoContracts/AutoContractsSourceGenerator.cs new file mode 100644 index 0000000..564252b --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsSourceGenerator.cs @@ -0,0 +1,33 @@ +using System.IO; + +namespace CaeriusNet.Generator.AutoContracts; + +/// +/// Generates SQL Server stored procedure contracts from the caerius.contracts.json manifest. +/// +[Generator(LanguageNames.CSharp)] +public sealed class AutoContractsSourceGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var manifests = context.AdditionalTextsProvider + .Where(static text => string.Equals( + Path.GetFileName(text.Path), + AutoContractsSourceNames.ManifestFileName, + StringComparison.OrdinalIgnoreCase)) + .Select(static (text, ct) => AutoContractsManifestParser.ParseOrDefault(text, ct)) + .Where(static manifest => manifest is not null) + .Select(static (manifest, _) => manifest!); + + context.RegisterSourceOutput(manifests, static (spc, manifest) => + { + if (!AutoContractsEmissionGuard.CanEmit(manifest)) + return; + + spc.AddSource( + AutoContractsSourceNames.BuildHintName(manifest), + AutoContractsEmitter.Emit(manifest)); + }); + } +} diff --git a/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs b/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs new file mode 100644 index 0000000..1d74a53 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs @@ -0,0 +1,11 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsSourceNames +{ + internal const string ManifestFileName = "caerius.contracts.json"; + + internal static string BuildHintName(AutoContractsManifest manifest) + { + return HintNameBuilder.Build(manifest.Namespace, "CaeriusContracts", "AutoContracts"); + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs b/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs new file mode 100644 index 0000000..20df1f0 --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs @@ -0,0 +1,244 @@ +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsSqlEmitter +{ + internal static string BuildSqlDbTypeExpression(string sqlType) + { + return NormalizeSqlType(sqlType) switch + { + "bit" => "SqlDbType.Bit", + "tinyint" => "SqlDbType.TinyInt", + "smallint" => "SqlDbType.SmallInt", + "int" => "SqlDbType.Int", + "bigint" => "SqlDbType.BigInt", + "real" => "SqlDbType.Real", + "float" => "SqlDbType.Float", + "decimal" => "SqlDbType.Decimal", + "numeric" => "SqlDbType.Decimal", + "money" => "SqlDbType.Money", + "smallmoney" => "SqlDbType.SmallMoney", + "date" => "SqlDbType.Date", + "time" => "SqlDbType.Time", + "datetime" => "SqlDbType.DateTime", + "datetime2" => "SqlDbType.DateTime2", + "smalldatetime" => "SqlDbType.SmallDateTime", + "datetimeoffset" => "SqlDbType.DateTimeOffset", + "uniqueidentifier" => "SqlDbType.UniqueIdentifier", + "nvarchar" => "SqlDbType.NVarChar", + "varchar" => "SqlDbType.VarChar", + "nchar" => "SqlDbType.NChar", + "char" => "SqlDbType.Char", + "varbinary" => "SqlDbType.VarBinary", + "binary" => "SqlDbType.Binary", + _ => "SqlDbType.Variant" + }; + } + + internal static string BuildReaderExpression(AutoContractsColumn column, int readerOrdinal) + { + var nullablePrefix = column.Nullable ? $"reader.IsDBNull({readerOrdinal}) ? null : " : string.Empty; + var normalized = NormalizeSqlType(column.SqlType); + + return normalized switch + { + "bit" => $"{nullablePrefix}reader.GetBoolean({readerOrdinal})", + "tinyint" => $"{nullablePrefix}reader.GetByte({readerOrdinal})", + "smallint" => $"{nullablePrefix}reader.GetInt16({readerOrdinal})", + "int" => $"{nullablePrefix}reader.GetInt32({readerOrdinal})", + "bigint" => $"{nullablePrefix}reader.GetInt64({readerOrdinal})", + "real" => $"{nullablePrefix}reader.GetFloat({readerOrdinal})", + "float" => $"{nullablePrefix}reader.GetDouble({readerOrdinal})", + "decimal" or "numeric" or "money" or "smallmoney" => + $"{nullablePrefix}reader.GetDecimal({readerOrdinal})", + "date" => $"{nullablePrefix}DateOnly.FromDateTime(reader.GetDateTime({readerOrdinal}))", + "time" => $"{nullablePrefix}TimeOnly.FromTimeSpan(reader.GetTimeSpan({readerOrdinal}))", + "datetime" or "datetime2" or "smalldatetime" => + $"{nullablePrefix}reader.GetDateTime({readerOrdinal})", + "datetimeoffset" => $"{nullablePrefix}reader.GetDateTimeOffset({readerOrdinal})", + "uniqueidentifier" => $"{nullablePrefix}reader.GetGuid({readerOrdinal})", + "nvarchar" or "varchar" or "nchar" or "char" => + $"{nullablePrefix}reader.GetString({readerOrdinal})", + "varbinary" or "binary" => + $"{nullablePrefix}reader.GetFieldValue({readerOrdinal})", + _ => $"{nullablePrefix}({ToNullableClrType(column)})reader.GetValue({readerOrdinal})" + }; + } + + internal static string BuildSqlMetaDataExpression(AutoContractsColumn column) + { + var columnName = ToStringLiteral(column.Name); + var normalized = NormalizeSqlType(column.SqlType); + + return normalized switch + { + "bit" => $"new SqlMetaData({columnName}, SqlDbType.Bit)", + "tinyint" => $"new SqlMetaData({columnName}, SqlDbType.TinyInt)", + "smallint" => $"new SqlMetaData({columnName}, SqlDbType.SmallInt)", + "int" => $"new SqlMetaData({columnName}, SqlDbType.Int)", + "bigint" => $"new SqlMetaData({columnName}, SqlDbType.BigInt)", + "real" => $"new SqlMetaData({columnName}, SqlDbType.Real)", + "float" => $"new SqlMetaData({columnName}, SqlDbType.Float)", + "decimal" or "numeric" => + $"new SqlMetaData({columnName}, SqlDbType.Decimal, {column.Precision ?? 18}, {column.Scale ?? 0})", + "money" => $"new SqlMetaData({columnName}, SqlDbType.Money)", + "smallmoney" => $"new SqlMetaData({columnName}, SqlDbType.SmallMoney)", + "date" => $"new SqlMetaData({columnName}, SqlDbType.Date)", + "time" => $"new SqlMetaData({columnName}, SqlDbType.Time, {column.Scale ?? 7})", + "datetime" => $"new SqlMetaData({columnName}, SqlDbType.DateTime)", + "datetime2" => $"new SqlMetaData({columnName}, SqlDbType.DateTime2, {column.Scale ?? 7})", + "smalldatetime" => $"new SqlMetaData({columnName}, SqlDbType.SmallDateTime)", + "datetimeoffset" => $"new SqlMetaData({columnName}, SqlDbType.DateTimeOffset, {column.Scale ?? 7})", + "uniqueidentifier" => $"new SqlMetaData({columnName}, SqlDbType.UniqueIdentifier)", + "nvarchar" => + $"new SqlMetaData({columnName}, SqlDbType.NVarChar, {AutoContractsSqlFacets.BuildColumnLength(column, true)})", + "nchar" => + $"new SqlMetaData({columnName}, SqlDbType.NChar, {AutoContractsSqlFacets.BuildColumnLength(column, true)})", + "varchar" => + $"new SqlMetaData({columnName}, SqlDbType.VarChar, {AutoContractsSqlFacets.BuildColumnLength(column, false)})", + "char" => + $"new SqlMetaData({columnName}, SqlDbType.Char, {AutoContractsSqlFacets.BuildColumnLength(column, false)})", + "varbinary" => + $"new SqlMetaData({columnName}, SqlDbType.VarBinary, {AutoContractsSqlFacets.BuildColumnLength(column, false)})", + "binary" => + $"new SqlMetaData({columnName}, SqlDbType.Binary, {AutoContractsSqlFacets.BuildColumnLength(column, false)})", + _ => $"new SqlMetaData({columnName}, SqlDbType.Variant)" + }; + } + + internal static string BuildSetExpression(AutoContractsColumn column, int ordinal, string valueExpression) + { + var normalized = NormalizeSqlType(column.SqlType); + var valueAccess = column.Nullable && AutoContractsClrTypes.IsNullableValueType(column.ClrType) + ? ".Value" + : string.Empty; + var value = valueExpression + valueAccess; + + return normalized switch + { + "bit" => $"record.SetBoolean({ordinal}, {value})", + "tinyint" => $"record.SetByte({ordinal}, {value})", + "smallint" => $"record.SetInt16({ordinal}, {value})", + "int" => $"record.SetInt32({ordinal}, {value})", + "bigint" => $"record.SetInt64({ordinal}, {value})", + "real" => $"record.SetFloat({ordinal}, {value})", + "float" => $"record.SetDouble({ordinal}, {value})", + "decimal" or "numeric" or "money" or "smallmoney" => $"record.SetDecimal({ordinal}, {value})", + "date" => $"record.SetDateTime({ordinal}, {value}.ToDateTime(TimeOnly.MinValue))", + "time" => $"record.SetTimeSpan({ordinal}, {value}.ToTimeSpan())", + "datetime" or "datetime2" or "smalldatetime" => $"record.SetDateTime({ordinal}, {value})", + "datetimeoffset" => $"record.SetDateTimeOffset({ordinal}, {value})", + "uniqueidentifier" => $"record.SetGuid({ordinal}, {value})", + "nvarchar" or "varchar" or "nchar" or "char" => $"record.SetString({ordinal}, {value})", + "varbinary" or "binary" => $"record.SetValue({ordinal}, {value})", + _ => $"record.SetValue({ordinal}, {value})" + }; + } + + internal static string ToNullableClrType(AutoContractsColumn column) + { + return ApplyNullability(column.ClrType, column.Nullable); + } + + internal static string ApplyNullability(string clrType, bool nullable) + { + if (!nullable || clrType.EndsWith("?", StringComparison.Ordinal)) + return clrType; + + return AutoContractsClrTypes.IsReferenceType(clrType) || AutoContractsClrTypes.IsNullableValueType(clrType) + ? clrType + "?" + : clrType + "?"; + } + + internal static bool IsSupportedSqlType(string sqlType) + { + return NormalizeSqlType(sqlType) switch + { + "bit" or "tinyint" or "smallint" or "int" or "bigint" or + "real" or "float" or "decimal" or "numeric" or "money" or "smallmoney" or + "date" or "time" or "datetime" or "datetime2" or "smalldatetime" or "datetimeoffset" or + "uniqueidentifier" or "nvarchar" or "varchar" or "nchar" or "char" or + "varbinary" or "binary" => true, + _ => false + }; + } + + internal static string ToIdentifier(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "Value"; + + var sb = new StringBuilder(name.Length + 1); + if (!IsIdentifierStart(name[0])) + sb.Append('_'); + + foreach (var ch in name) + sb.Append(IsIdentifierPart(ch) ? ch : '_'); + + var identifier = sb.ToString(); + return IsKeyword(identifier) ? "@" + identifier : identifier; + } + + internal static string ToStringLiteral(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var ch in value) + switch (ch) + { + case '\\': + sb.Append(@"\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append(@"\n"); + break; + case '\r': + sb.Append(@"\r"); + break; + case '\t': + sb.Append(@"\t"); + break; + default: + if (char.IsControl(ch)) + sb.Append(@"\u").Append(((int)ch).ToString("x4")); + else + sb.Append(ch); + break; + } + + sb.Append('"'); + return sb.ToString(); + } + + internal static string NormalizeSqlType(string sqlType) + { + var span = sqlType.AsSpan().Trim(); + var paren = span.IndexOf('('); + if (paren >= 0) + span = span.Slice(0, paren); + + var dot = span.LastIndexOf('.'); + if (dot >= 0) + span = span.Slice(dot + 1); + + return span.ToString().ToLowerInvariant(); + } + + private static bool IsIdentifierStart(char ch) + { + return ch == '_' || char.IsLetter(ch); + } + + private static bool IsIdentifierPart(char ch) + { + return ch == '_' || char.IsLetterOrDigit(ch); + } + + private static bool IsKeyword(string identifier) + { + return identifier is "class" or "struct" or "record" or "namespace" or "public" or "private" or + "internal" or "readonly" or "string" or "int" or "bool" or "decimal" or "object"; + } +} \ No newline at end of file diff --git a/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs b/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs new file mode 100644 index 0000000..4eb317b --- /dev/null +++ b/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs @@ -0,0 +1,62 @@ +using System.Globalization; + +namespace CaeriusNet.Generator.AutoContracts; + +internal static class AutoContractsSqlFacets +{ + internal static string BuildParameterArguments(AutoContractsParameter parameter) + { + var normalized = AutoContractsSqlEmitter.NormalizeSqlType(parameter.SqlType); + var parts = new List(3); + + if (parameter.MaxLength is { } maxLength && IsSizedType(normalized)) + { + var size = ToSqlClientSize(normalized, maxLength); + parts.Add("size: " + size.ToString(CultureInfo.InvariantCulture)); + } + + if (parameter.Precision is { } precision && IsDecimalType(normalized)) + parts.Add("precision: " + precision.ToString(CultureInfo.InvariantCulture)); + + if (parameter.Scale is { } scale && IsScaledType(normalized)) + parts.Add("scale: " + scale.ToString(CultureInfo.InvariantCulture)); + + return string.Join(", ", parts); + } + + internal static string BuildColumnLength(AutoContractsColumn column, bool divideByTwo) + { + if (column.MaxLength is null or -1) + return "SqlMetaData.Max"; + + var length = column.MaxLength.Value; + if (divideByTwo) + length /= 2; + + return Math.Max(length, 1).ToString(CultureInfo.InvariantCulture); + } + + private static int ToSqlClientSize(string normalizedSqlType, int maxLength) + { + var size = maxLength; + if (normalizedSqlType is "nvarchar" or "nchar" && size > 0) + size /= 2; + + return Math.Max(size, -1); + } + + private static bool IsSizedType(string normalizedSqlType) + { + return normalizedSqlType is "nvarchar" or "nchar" or "varchar" or "char" or "varbinary" or "binary"; + } + + private static bool IsDecimalType(string normalizedSqlType) + { + return normalizedSqlType is "decimal" or "numeric"; + } + + private static bool IsScaledType(string normalizedSqlType) + { + return normalizedSqlType is "decimal" or "numeric" or "time" or "datetime2" or "datetimeoffset"; + } +} \ No newline at end of file diff --git a/SourceGenerators/CaeriusNet.Generator.csproj b/SourceGenerators/CaeriusNet.Generator.csproj index f18498a..a134050 100644 --- a/SourceGenerators/CaeriusNet.Generator.csproj +++ b/SourceGenerators/CaeriusNet.Generator.csproj @@ -1,4 +1,5 @@ - + netstandard2.0 @@ -11,6 +12,11 @@ true CaeriusNet.Generator true + false + false + false + false + false $(NoWarn);RS2008 diff --git a/Src/Abstractions/IRedisCacheManager.cs b/Src/Abstractions/IRedisCacheManager.cs index 4deb00d..b928181 100644 --- a/Src/Abstractions/IRedisCacheManager.cs +++ b/Src/Abstractions/IRedisCacheManager.cs @@ -10,14 +10,32 @@ public interface IRedisCacheManager /// bool TryGet(string cacheKey, out T? value); + /// + /// Attempts to retrieve a cached value using source-generated JSON metadata. + /// + bool TryGet(string cacheKey, JsonTypeInfo jsonTypeInfo, out T? value) + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + return TryGet(cacheKey, out value); + } + /// /// Stores a value in the cache. /// void Store(string cacheKey, T value, TimeSpan? expiration) where T : notnull; + /// + /// Stores a value using source-generated JSON metadata. + /// + void Store(string cacheKey, T value, TimeSpan? expiration, JsonTypeInfo jsonTypeInfo) where T : notnull + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + Store(cacheKey, value, expiration); + } + /// /// Removes the entry associated with the specified key. No-op if Redis is not configured /// or the key does not exist. /// void Remove(string cacheKey); -} \ No newline at end of file +} diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs new file mode 100644 index 0000000..15a037d --- /dev/null +++ b/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs @@ -0,0 +1,20 @@ +namespace CaeriusNet.Attributes.AutoContracts; + +/// +/// Marks a result DTO emitted by AutoContracts from a SQL Server contract manifest. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class AutoContractGenerateDtoAttribute : Attribute +{ + /// Gets or sets the SQL Server schema name. + public string Schema { get; set; } = string.Empty; + + /// Gets or sets the SQL Server stored procedure name. + public string Procedure { get; set; } = string.Empty; + + /// Gets or sets the zero-based result-set index. + public int ResultSetIndex { get; set; } + + /// Gets or sets the manifest contract hash. + public string ContractHash { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs new file mode 100644 index 0000000..77dc006 --- /dev/null +++ b/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs @@ -0,0 +1,17 @@ +namespace CaeriusNet.Attributes.AutoContracts; + +/// +/// Marks a stored procedure descriptor emitted by AutoContracts from a SQL Server contract manifest. +/// +[AttributeUsage(AttributeTargets.Struct)] +public sealed class AutoContractGenerateProcedureAttribute : Attribute +{ + /// Gets or sets the SQL Server schema name. + public string Schema { get; set; } = string.Empty; + + /// Gets or sets the SQL Server stored procedure name. + public string Name { get; set; } = string.Empty; + + /// Gets or sets the manifest contract hash. + public string ContractHash { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs new file mode 100644 index 0000000..9fc16bd --- /dev/null +++ b/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs @@ -0,0 +1,17 @@ +namespace CaeriusNet.Attributes.AutoContracts; + +/// +/// Marks a TVP row type emitted by AutoContracts from a SQL Server contract manifest. +/// +[AttributeUsage(AttributeTargets.Struct)] +public sealed class AutoContractGenerateTvpAttribute : Attribute +{ + /// Gets or sets the SQL Server schema name. + public string Schema { get; set; } = string.Empty; + + /// Gets or sets the SQL Server user-defined table type name. + public string TypeName { get; set; } = string.Empty; + + /// Gets or sets the manifest contract hash. + public string ContractHash { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Src/Builders/ICaeriusGeneratedProcedureParameters.cs b/Src/Builders/ICaeriusGeneratedProcedureParameters.cs new file mode 100644 index 0000000..7ab38e5 --- /dev/null +++ b/Src/Builders/ICaeriusGeneratedProcedureParameters.cs @@ -0,0 +1,16 @@ +namespace CaeriusNet.Builders; + +/// +/// Binds a generated parameter record to a typed stored procedure builder. +/// +/// Generated procedure descriptor type. +/// Generated parameter record type. +public interface ICaeriusGeneratedProcedureParameters + where TProcedure : struct, ICaeriusGeneratedProcedure + where TSelf : ICaeriusGeneratedProcedureParameters +{ + /// + /// Adds all generated SQL parameters to the supplied builder in manifest ordinal order. + /// + static abstract void Bind(StoredProcedureParametersBuilder builder, TSelf parameters); +} diff --git a/Src/Builders/StoredProcedureParametersBuilder.Typed.cs b/Src/Builders/StoredProcedureParametersBuilder.Typed.cs new file mode 100644 index 0000000..03c15fc --- /dev/null +++ b/Src/Builders/StoredProcedureParametersBuilder.Typed.cs @@ -0,0 +1,275 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace CaeriusNet.Builders; + +/// +/// Maps a generated TVP row into a reusable . +/// +/// The generated TVP row type. +/// The SQL data record to populate. +/// The generated TVP row value. +public delegate void GeneratedTvpRowMapper(SqlDataRecord record, T row); + +/// +/// A typed stored procedure parameter builder for generated SQL Server contracts. +/// +/// The generated procedure descriptor type. +public sealed class StoredProcedureParametersBuilder + where TProcedure : struct, ICaeriusGeneratedProcedure +{ + private readonly StoredProcedureParametersBuilder _inner; + private int _generatedParameterCount; + private bool _generatedParametersBound; + + private StoredProcedureParametersBuilder(int resultSetCapacity, int commandTimeout) + { + _inner = new StoredProcedureParametersBuilder( + TProcedure.SchemaName, + TProcedure.ProcedureName, + resultSetCapacity, + commandTimeout); + + _generatedParametersBound = TProcedure.ParameterCount == 0; + } + + /// + /// Creates a typed builder for . + /// + /// Expected result-set capacity used for collection preallocation. + /// Command timeout in seconds. + [SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = + "The generated API intentionally follows StoredProcedureParametersBuilder.Create(...).")] + public static StoredProcedureParametersBuilder Create( + int resultSetCapacity, + int commandTimeout = 30) + { + return new StoredProcedureParametersBuilder(resultSetCapacity, commandTimeout); + } + + /// + /// Creates a typed builder and binds a generated parameter record in one step. + /// + /// Generated parameter record type for . + /// Generated parameter record. + /// Expected result-set capacity used for collection preallocation. + /// Command timeout in seconds. + [SuppressMessage( + "Design", + "CA1000:Do not declare static members on generic types", + Justification = + "The generated API intentionally follows StoredProcedureParametersBuilder.Create(...).")] + public static StoredProcedureParametersBuilder Create( + TParameters parameters, + int resultSetCapacity, + int commandTimeout = 30) + where TParameters : ICaeriusGeneratedProcedureParameters + { + ArgumentNullException.ThrowIfNull(parameters); + var builder = Create(resultSetCapacity, commandTimeout); + TParameters.Bind(builder, parameters); + return builder; + } + + /// + /// Adds a generated scalar parameter in manifest ordinal order. + /// + public StoredProcedureParametersBuilder AddGeneratedParameter( + int ordinal, + string name, + object? value, + SqlDbType dbType, + int? size = null, + byte? precision = null, + byte? scale = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var parameter = new SqlParameter(name, dbType) { Value = value ?? DBNull.Value }; + if (size is { } actualSize) + parameter.Size = actualSize; + if (precision is { } actualPrecision) + parameter.Precision = actualPrecision; + if (scale is { } actualScale) + parameter.Scale = actualScale; + + return AddGeneratedSqlParameter(ordinal, parameter); + } + + /// + /// Adds a generated SQL parameter in manifest ordinal order. + /// + public StoredProcedureParametersBuilder AddGeneratedParameter( + int ordinal, + SqlParameter parameter) + { + return AddGeneratedSqlParameter(ordinal, parameter); + } + + /// + /// Adds a generated TVP parameter backed by rows. + /// + public StoredProcedureParametersBuilder AddGeneratedTvpParameter( + int ordinal, + string name, + string typeName, + SqlMetaData[] metadata, + ReadOnlyMemory rows, + GeneratedTvpRowMapper mapRow) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(typeName); + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(mapRow); + if (metadata.Length == 0) + throw new ArgumentException( + $"Generated TVP parameter '{name}' for procedure '{TProcedure.FullName}' must declare at least one column.", + nameof(metadata)); + + var parameter = new SqlParameter(name, SqlDbType.Structured) + { + TypeName = typeName, + Value = new ReadOnlyMemoryTvpParameterValue(metadata, rows, mapRow) + }; + + return AddGeneratedSqlParameter(ordinal, parameter); + } + + /// + /// Marks the generated parameter set as complete. + /// + public StoredProcedureParametersBuilder MarkGeneratedParametersBound() + { + if (_generatedParameterCount != TProcedure.ParameterCount) + throw new InvalidOperationException( + $"Generated procedure '{TProcedure.FullName}' expects {TProcedure.ParameterCount} generated parameter(s) but {_generatedParameterCount} were bound."); + + _generatedParametersBound = true; + return this; + } + + /// + /// Configures in-memory caching. + /// + public StoredProcedureParametersBuilder AddInMemoryCache(string cacheKey, TimeSpan expiration) + { + _inner.AddInMemoryCache(cacheKey, expiration); + return this; + } + + /// + /// Configures frozen caching. + /// + public StoredProcedureParametersBuilder AddFrozenCache(string cacheKey) + { + _inner.AddFrozenCache(cacheKey); + return this; + } + + /// + /// Configures Redis caching. + /// + public StoredProcedureParametersBuilder AddRedisCache(string cacheKey, TimeSpan? expiration = null) + { + _inner.AddRedisCache(cacheKey, expiration); + return this; + } + + /// + /// Builds typed stored procedure parameters for the generated procedure descriptor. + /// + public StoredProcedureParameters Build() + { + if (!_generatedParametersBound) + throw new InvalidOperationException( + $"Generated parameters for procedure '{TProcedure.FullName}' were not bound. Call the generated WithParameters extension before Build."); + + return new StoredProcedureParameters(_inner.Build()); + } + + private void ValidateGeneratedOrdinal(int ordinal) + { + if (ordinal < 1) + throw new ArgumentOutOfRangeException( + nameof(ordinal), + ordinal, + $"Generated parameter ordinals for procedure '{TProcedure.FullName}' are 1-based."); + + if (ordinal > TProcedure.ParameterCount) + throw new ArgumentOutOfRangeException( + nameof(ordinal), + ordinal, + $"Generated procedure '{TProcedure.FullName}' expects only {TProcedure.ParameterCount} generated parameter(s)."); + + var expectedOrdinal = _generatedParameterCount + 1; + if (ordinal != expectedOrdinal) + throw new ArgumentOutOfRangeException( + nameof(ordinal), + ordinal, + $"Generated parameters for procedure '{TProcedure.FullName}' must be added in manifest order. Expected ordinal {expectedOrdinal}."); + } + + private StoredProcedureParametersBuilder AddGeneratedSqlParameter(int ordinal, SqlParameter parameter) + { + ValidateGeneratedOrdinal(ordinal); + ArgumentNullException.ThrowIfNull(parameter); + ArgumentException.ThrowIfNullOrEmpty(parameter.ParameterName); + + parameter.Value ??= DBNull.Value; + _inner.AddParameter(parameter); + _generatedParameterCount++; + _generatedParametersBound = false; + return this; + } + + private sealed class ReadOnlyMemoryTvpParameterValue( + SqlMetaData[] metadata, + ReadOnlyMemory rows, + GeneratedTvpRowMapper mapRow) : IEnumerable + { + public IEnumerator GetEnumerator() + { + return new Enumerator(metadata, rows, mapRow); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private sealed class Enumerator( + SqlMetaData[] metadata, + ReadOnlyMemory rows, + GeneratedTvpRowMapper mapRow) : IEnumerator + { + private int _index = -1; + + public SqlDataRecord Current { get; } = new(metadata); + + object IEnumerator.Current => Current; + + public bool MoveNext() + { + var next = _index + 1; + if (next >= rows.Length) + return false; + + _index = next; + mapRow(Current, rows.Span[next]); + return true; + } + + public void Reset() + { + _index = -1; + } + + public void Dispose() + { + } + } + } +} diff --git a/Src/Builders/StoredProcedureParametersBuilder.cs b/Src/Builders/StoredProcedureParametersBuilder.cs index 387e6b4..cdd7f2a 100644 --- a/Src/Builders/StoredProcedureParametersBuilder.cs +++ b/Src/Builders/StoredProcedureParametersBuilder.cs @@ -61,6 +61,14 @@ public StoredProcedureParametersBuilder AddParameter(string parameter, object? v return this; } + internal StoredProcedureParametersBuilder AddParameter(SqlParameter parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentException.ThrowIfNullOrEmpty(parameter.ParameterName); + Parameters.Add(parameter); + return this; + } + /// /// Adds a Table-Valued Parameter (TVP) to the stored procedure call. /// diff --git a/Src/Caches/RedisCacheManager.cs b/Src/Caches/RedisCacheManager.cs index 9a9ac45..3bc4c53 100644 --- a/Src/Caches/RedisCacheManager.cs +++ b/Src/Caches/RedisCacheManager.cs @@ -54,6 +54,14 @@ public RedisCacheManager(IDistributedCache? distributedCache, ILoggerFactory? lo /// /// Stores a value in the Redis cache. /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] [MethodImpl(MethodImplOptions.AggressiveOptimization)] public void Store(string cacheKey, T value, TimeSpan? expiration) where T : notnull { @@ -90,9 +98,57 @@ public void Store(string cacheKey, T value, TimeSpan? expiration) where T : n } } + /// + /// Stores a value in the Redis cache using source-generated JSON metadata. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public void Store(string cacheKey, T value, TimeSpan? expiration, JsonTypeInfo jsonTypeInfo) where T : notnull + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + if (_distributedCache == null) return; + + try + { + _logger?.LogStoringInRedis(cacheKey); + + var bufferWriter = BufferWriterPool.Value!; + bufferWriter.Clear(); + + using (var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions + { + SkipValidation = true, + Indented = false + })) + { + JsonSerializer.Serialize(writer, value, jsonTypeInfo); + } + + var options = new DistributedCacheEntryOptions(); + if (expiration.HasValue) + options.AbsoluteExpirationRelativeToNow = expiration.Value; + + var written = bufferWriter.WrittenSpan; + var bytes = GC.AllocateUninitializedArray(written.Length); + written.CopyTo(bytes); + _distributedCache.Set(cacheKey, bytes, options); + } + catch (Exception ex) + { + _logger?.LogRedisStoreError(cacheKey, ex); + } + } + /// /// Attempts to retrieve a cached value from Redis. /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] [MethodImpl(MethodImplOptions.AggressiveOptimization)] public bool TryGet(string cacheKey, out T? value) { @@ -120,6 +176,37 @@ public bool TryGet(string cacheKey, out T? value) } } + /// + /// Attempts to retrieve a cached value from Redis using source-generated JSON metadata. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public bool TryGet(string cacheKey, JsonTypeInfo jsonTypeInfo, out T? value) + { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + value = default; + if (_distributedCache == null) return false; + + try + { + var cached = _distributedCache.Get(cacheKey); + if (cached == null || cached.Length == 0) return false; + + ReadOnlySpan bytes = cached; + var reader = new Utf8JsonReader(bytes); + value = JsonSerializer.Deserialize(ref reader, jsonTypeInfo); + + if (value != null && _logger != null) + _logger.LogRetrievedFromRedis(cacheKey); + + return value != null; + } + catch (Exception ex) + { + _logger?.LogRedisTryGetFailed(cacheKey, ex); + return false; + } + } + /// /// Removes the entry associated with the specified key. Failures are logged and swallowed /// to keep the call site oblivious to transient distributed-cache errors. @@ -138,4 +225,4 @@ public void Remove(string cacheKey) _logger?.LogRedisStoreError(cacheKey, ex); } } -} \ No newline at end of file +} diff --git a/Src/CaeriusNet.csproj b/Src/CaeriusNet.csproj index 84a370d..2329962 100644 --- a/Src/CaeriusNet.csproj +++ b/Src/CaeriusNet.csproj @@ -56,8 +56,7 @@ true true true - - $(NoWarn);IL2026;IL3050;CS0419;CS1574;CS1591 + $(NoWarn);CS0419;CS1574;CS1591 @@ -109,10 +108,12 @@ + PrivateAssets="all" + GlobalPropertiesToRemove="IsAotCompatible;IsTrimmable;EnableAotAnalyzer;EnableTrimAnalyzer;EnableSingleFileAnalyzer;PublishAot;PublishTrimmed;PublishSingleFile"/> + PrivateAssets="all" + GlobalPropertiesToRemove="IsAotCompatible;IsTrimmable;EnableAotAnalyzer;EnableTrimAnalyzer;EnableSingleFileAnalyzer;PublishAot;PublishTrimmed;PublishSingleFile"/> diff --git a/Src/GlobalUsings.cs b/Src/GlobalUsings.cs index c8135e6..7b5c1a3 100644 --- a/Src/GlobalUsings.cs +++ b/Src/GlobalUsings.cs @@ -23,10 +23,12 @@ global using StackExchange.Redis; global using System.Buffers; global using System.CodeDom.Compiler; +global using System.Diagnostics.CodeAnalysis; global using System.Runtime.CompilerServices; global using System.Runtime.InteropServices; global using System.Text.Json.Serialization; +global using System.Text.Json.Serialization.Metadata; global using static CaeriusNet.Helpers.CacheType; [assembly: InternalsVisibleTo("CaeriusNet.Tests")] -[assembly: CLSCompliant(false)] \ No newline at end of file +[assembly: CLSCompliant(false)] diff --git a/Src/Mappers/ICaeriusGeneratedProcedure.cs b/Src/Mappers/ICaeriusGeneratedProcedure.cs new file mode 100644 index 0000000..664df58 --- /dev/null +++ b/Src/Mappers/ICaeriusGeneratedProcedure.cs @@ -0,0 +1,27 @@ +namespace CaeriusNet.Mappers; + +/// +/// Describes a stored procedure contract generated from SQL Server metadata. +/// +/// The generated procedure descriptor type. +public interface ICaeriusGeneratedProcedure + where TSelf : struct, ICaeriusGeneratedProcedure +{ + /// Gets the SQL Server schema name. + public static abstract string SchemaName { get; } + + /// Gets the SQL Server stored procedure name. + public static abstract string ProcedureName { get; } + + /// Gets the fully qualified SQL Server stored procedure name. + public static abstract string FullName { get; } + + /// Gets the stable contract hash from the manifest. + public static abstract string ContractHash { get; } + + /// Gets the number of generated procedure parameters. + public static abstract int ParameterCount { get; } + + /// Gets the number of generated result sets supported by this descriptor. + public static abstract int ResultSetCount { get; } +} \ No newline at end of file diff --git a/Src/Mappers/StoredProcedureParameters.Typed.cs b/Src/Mappers/StoredProcedureParameters.Typed.cs new file mode 100644 index 0000000..de652b5 --- /dev/null +++ b/Src/Mappers/StoredProcedureParameters.Typed.cs @@ -0,0 +1,71 @@ +namespace CaeriusNet.Mappers; + +/// +/// Typed wrapper over for generated procedure contracts. +/// +/// The generated procedure descriptor type. +public sealed record StoredProcedureParameters + where TProcedure : struct, ICaeriusGeneratedProcedure +{ + private readonly StoredProcedureParameters _parameters; + + /// + /// Initializes a new typed wrapper around untyped stored procedure parameters. + /// + /// The untyped parameters produced by the runtime builder. + public StoredProcedureParameters(StoredProcedureParameters parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + _parameters = parameters; + } + + /// Gets the SQL Server schema name. + public string SchemaName => _parameters.SchemaName; + + /// Gets the SQL Server stored procedure name. + public string ProcedureName => _parameters.ProcedureName; + + /// Gets the fully qualified SQL Server stored procedure name. + public string FullName => TProcedure.FullName; + + /// Gets the stable contract hash from the generated manifest. + public string ContractHash => TProcedure.ContractHash; + + /// Gets the generated procedure parameter count. + public int ParameterCount => TProcedure.ParameterCount; + + /// Gets the generated procedure result-set count. + public int ResultSetCount => TProcedure.ResultSetCount; + + /// Gets the expected result-set capacity. + public int Capacity => _parameters.Capacity; + + /// Gets the optional cache key. + public string? CacheKey => _parameters.CacheKey; + + /// Gets the optional cache expiration. + public TimeSpan? CacheExpiration => _parameters.CacheExpiration; + + /// Gets the optional cache type. + public CacheType? CacheType => _parameters.CacheType; + + /// Gets the command timeout in seconds. + public int CommandTimeout => _parameters.CommandTimeout; + + /// + /// Exposes the underlying untyped parameters for existing CaeriusNet execution APIs. + /// + public StoredProcedureParameters AsUntyped() + { + return _parameters; + } + + /// + /// Gets parameters as a read-only span for diagnostics and tests. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan GetParametersSpan() + { + return _parameters.GetParametersSpan(); + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Generator.Tests/AutoContracts/AutoContractsSourceGeneratorTests.cs b/Tests/CaeriusNet.Generator.Tests/AutoContracts/AutoContractsSourceGeneratorTests.cs new file mode 100644 index 0000000..f2cd0f1 --- /dev/null +++ b/Tests/CaeriusNet.Generator.Tests/AutoContracts/AutoContractsSourceGeneratorTests.cs @@ -0,0 +1,222 @@ +namespace CaeriusNet.Generator.Tests.AutoContracts; + +public sealed class AutoContractsSourceGeneratorTests +{ + private const string ValidManifest = """ + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [ + { + "schema": "dbo", + "name": "UserIdList", + "clrName": "UserIdListTvp", + "columns": [ + { + "ordinal": 1, + "name": "UserId", + "sqlType": "int", + "clrType": "int", + "nullable": false + } + ], + "contractHash": "sha256:table" + } + ], + "procedures": [ + { + "schema": "dbo", + "name": "User_RetrieveByIds", + "clrName": "UserRetrieveByIdsProcedure", + "parametersClrName": "UserRetrieveByIdsParameters", + "resultClrName": "UserRetrieveByIdsResult", + "parameters": [ + { + "ordinal": 1, + "name": "Ids", + "sqlType": "dbo.UserIdList", + "clrType": "ReadOnlyMemory", + "isTableType": true, + "nullable": false + }, + { + "ordinal": 2, + "name": "IncludeDisabled", + "sqlType": "bit", + "clrType": "bool", + "isTableType": false, + "nullable": false + } + ], + "resultSet": { + "status": "Available", + "columns": [ + { + "ordinal": 1, + "name": "UserId", + "sqlType": "int", + "clrType": "int", + "nullable": false + }, + { + "ordinal": 2, + "name": "Username", + "sqlType": "nvarchar(128)", + "clrType": "string", + "nullable": false, + "maxLength": 256 + } + ] + }, + "contractHash": "sha256:procedure" + } + ] + } + """; + + [Fact] + public void ValidManifest_Generates_Procedure_Tvp_Dto_And_Builder_Extensions() + { + var result = SourceGeneratorTestHelper.RunGenerator( + "namespace Consumer;", + Manifest(ValidManifest)); + + Assert.Single(result.GeneratedTrees); + var generated = result.GeneratedTrees[0].GetText().ToString(); + + Assert.Contains("public readonly partial record struct UserIdListTvp", generated); + Assert.Contains("public readonly partial struct UserRetrieveByIdsProcedure", generated); + Assert.Contains("public sealed partial record UserRetrieveByIdsParameters", generated); + Assert.Contains("ICaeriusGeneratedProcedureParameters", + generated); + Assert.Contains("public static void Bind(StoredProcedureParametersBuilder builder", + generated); + Assert.Contains("public sealed partial record UserRetrieveByIdsResult", generated); + Assert.Contains("public static partial class UserRetrieveByIdsProcedureCacheKeys", generated); + Assert.Contains("public static string ByParameters(UserRetrieveByIdsParameters parameters)", generated); + Assert.Contains("ReadOnlyMemory Ids,", generated); + Assert.Contains("bool IncludeDisabled", generated); + Assert.Contains("return builder.WithParameters(new UserRetrieveByIdsParameters(Ids, IncludeDisabled));", + generated); + Assert.Contains("[AutoContractGenerateTvp", generated); + Assert.Contains("[AutoContractGenerateProcedure", generated); + Assert.Contains("[AutoContractGenerateDto", generated); + Assert.Contains("StoredProcedureParametersBuilder", generated); + Assert.Contains("reader.GetInt32(0)", generated); + Assert.Contains("reader.GetString(1)", generated); + Assert.DoesNotContain("[Caerius" + "GeneratedTvp", generated, StringComparison.Ordinal); + Assert.DoesNotContain("[Caerius" + "GeneratedProcedure", generated, StringComparison.Ordinal); + Assert.DoesNotContain("[Caerius" + "GeneratedDto", generated, StringComparison.Ordinal); + Assert.DoesNotContain($"V{1}", generated, StringComparison.Ordinal); + Assert.DoesNotContain($"v{1}", generated, StringComparison.Ordinal); + } + + [Fact] + public void ValidManifest_Generated_Code_Compiles() + { + var (_, compilation) = SourceGeneratorTestHelper.RunGeneratorWithCompilation( + "namespace Consumer;", + Manifest(ValidManifest)); + + var diagnostics = compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Where(diagnostic => diagnostic.Id != "CS0009") + .ToArray(); + + Assert.Empty(diagnostics); + } + + [Fact] + public void NonManifestAdditionalFile_Is_Ignored() + { + var result = SourceGeneratorTestHelper.RunGenerator( + "namespace Consumer;", + new TestAdditionalText("ignored.json", ValidManifest)); + + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void ScalarParameterFacets_Are_Emitted() + { + var result = SourceGeneratorTestHelper.RunGenerator( + "namespace Consumer;", + Manifest(""" + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [], + "procedures": [ + { + "schema": "dbo", + "name": "SearchUsers", + "clrName": "SearchUsersProcedure", + "parametersClrName": "SearchUsersParameters", + "parameters": [ + { + "ordinal": 1, + "name": "Name", + "sqlType": "nvarchar(64)", + "clrType": "string", + "isTableType": false, + "nullable": false, + "maxLength": 128 + }, + { + "ordinal": 2, + "name": "Amount", + "sqlType": "decimal(18,2)", + "clrType": "decimal", + "isTableType": false, + "nullable": false, + "precision": 18, + "scale": 2 + } + ], + "resultSet": { "status": "None", "columns": [] }, + "contractHash": "sha256:test" + } + ] + } + """)); + + var generated = result.GeneratedTrees[0].GetText().ToString(); + + Assert.Contains("SqlDbType.NVarChar, size: 64", generated); + Assert.Contains("SqlDbType.Decimal, precision: 18, scale: 2", generated); + } + + [Fact] + public void UndeterminedResultSet_Is_Not_Emitted_By_Generator() + { + var result = SourceGeneratorTestHelper.RunGenerator( + "namespace Consumer;", + Manifest(""" + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [], + "procedures": [ + { + "schema": "dbo", + "name": "SomeProcedure", + "clrName": "SomeProcedure", + "parametersClrName": "SomeProcedureParameters", + "resultClrName": "SomeProcedureResult", + "parameters": [], + "resultSet": { "status": "Undetermined", "columns": [] }, + "contractHash": "sha256:test" + } + ] + } + """)); + + Assert.Empty(result.Diagnostics); + Assert.Empty(result.GeneratedTrees); + } + + private static TestAdditionalText Manifest(string json) + { + return new TestAdditionalText("caerius.contracts.json", json); + } +} diff --git a/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs index 11bf33d..c3227b9 100644 --- a/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs @@ -8,5 +8,6 @@ global using CaeriusNet.Mappers; global using CaeriusNet.Generator.Dto; global using CaeriusNet.Generator.Tvp; +global using CaeriusNet.Generator.AutoContracts; global using CaeriusNet.Generator.Helpers; global using CaeriusNet.Generator.Tests.Utilities; \ No newline at end of file diff --git a/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs b/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs index 97dba54..00cfe16 100644 --- a/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs +++ b/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs @@ -1,3 +1,6 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis.Text; using Microsoft.Data.SqlClient; namespace CaeriusNet.Generator.Tests.Utilities; @@ -12,6 +15,14 @@ internal static class SourceGeneratorTestHelper /// internal static GeneratorDriverRunResult RunGenerator(string source) where TGenerator : IIncrementalGenerator, new() + { + return RunGenerator(source, []); + } + + internal static GeneratorDriverRunResult RunGenerator( + string source, + params AdditionalText[] additionalTexts) + where TGenerator : IIncrementalGenerator, new() { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); @@ -19,6 +30,7 @@ internal static GeneratorDriverRunResult RunGenerator(string source) var generator = new TGenerator(); var driver = CSharpGeneratorDriver.Create(generator) + .AddAdditionalTexts(additionalTexts.ToImmutableArray()) .WithUpdatedParseOptions(parseOptions); return driver.RunGenerators(compilation).GetRunResult(); @@ -28,6 +40,15 @@ internal static (GeneratorDriverRunResult RunResult, Compilation Compilation) RunGeneratorWithCompilation( string source) where TGenerator : IIncrementalGenerator, new() + { + return RunGeneratorWithCompilation(source, []); + } + + internal static (GeneratorDriverRunResult RunResult, Compilation Compilation) + RunGeneratorWithCompilation( + string source, + params AdditionalText[] additionalTexts) + where TGenerator : IIncrementalGenerator, new() { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); @@ -35,6 +56,7 @@ internal static (GeneratorDriverRunResult RunResult, Compilation Compilation) var generator = new TGenerator(); var driver = CSharpGeneratorDriver.Create(generator) + .AddAdditionalTexts(additionalTexts.ToImmutableArray()) .WithUpdatedParseOptions(parseOptions); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); @@ -94,4 +116,14 @@ private static IReadOnlyList BuildMetadataReferences() return references; } +} + +internal sealed class TestAdditionalText(string path, string text) : AdditionalText +{ + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return SourceText.From(text, Encoding.UTF8); + } } \ No newline at end of file diff --git a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs new file mode 100644 index 0000000..f99150b --- /dev/null +++ b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs @@ -0,0 +1,277 @@ +namespace CaeriusNet.Tests.Builders; + +public sealed class StoredProcedureParametersBuilderTypedTests +{ + [Fact] + public void Create_Uses_Generated_Procedure_Metadata() + { + var sp = StoredProcedureParametersBuilder + .Create(32, 45) + .Build(); + + Assert.Equal("dbo", sp.SchemaName); + Assert.Equal("usp_NoParameters", sp.ProcedureName); + Assert.Equal("dbo.usp_NoParameters", sp.FullName); + Assert.Equal("sha256:test", sp.ContractHash); + Assert.Equal(0, sp.ParameterCount); + Assert.Equal(1, sp.ResultSetCount); + Assert.Equal(32, sp.Capacity); + Assert.Equal(45, sp.CommandTimeout); + } + + [Fact] + public void Build_With_Unbound_Generated_Parameters_Throws() + { + var builder = StoredProcedureParametersBuilder + .Create(16); + + var exception = Assert.Throws(() => builder.Build()); + + Assert.Contains("WithParameters", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Build_With_Bound_Generated_Scalar_Parameters_Returns_Typed_Parameters() + { + var sp = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, "@Id", 42, SqlDbType.Int) + .MarkGeneratedParametersBound() + .AddRedisCache("test:key", TimeSpan.FromMinutes(5)) + .Build(); + + var parameters = sp.GetParametersSpan(); + + Assert.Equal("dbo", sp.SchemaName); + Assert.Equal("usp_ById", sp.ProcedureName); + Assert.Equal(CacheType.Redis, sp.CacheType); + Assert.Equal("test:key", sp.CacheKey); + Assert.Single(parameters.ToArray()); + Assert.Equal("@Id", parameters[0].ParameterName); + Assert.Equal(SqlDbType.Int, parameters[0].SqlDbType); + Assert.Equal(42, parameters[0].Value); + } + + [Fact] + public void Create_With_Generated_Parameters_Binds_In_One_Call() + { + var sp = StoredProcedureParametersBuilder + .Create(new TestScalarParameters(42), resultSetCapacity: 16) + .Build(); + + var parameters = sp.GetParametersSpan(); + + Assert.Equal(16, sp.Capacity); + Assert.Single(parameters.ToArray()); + Assert.Equal("@Id", parameters[0].ParameterName); + Assert.Equal(42, parameters[0].Value); + } + + [Fact] + public void AddGeneratedParameter_Preserves_Sql_Facets() + { + var sp = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, "@Name", "ari", SqlDbType.NVarChar, 64) + .AddGeneratedParameter(2, "@Amount", 12.50m, SqlDbType.Decimal, precision: 18, scale: 2) + .MarkGeneratedParametersBound() + .Build(); + + var parameters = sp.GetParametersSpan(); + + Assert.Equal(64, parameters[0].Size); + Assert.Equal(18, parameters[1].Precision); + Assert.Equal(2, parameters[1].Scale); + } + + [Fact] + public void AddGeneratedParameter_With_Configured_SqlParameter_Preserves_Facets() + { + var parameter = new SqlParameter("@Amount", SqlDbType.Decimal) + { + Value = 12.50m, + Precision = 18, + Scale = 4 + }; + + var sp = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, parameter) + .MarkGeneratedParametersBound() + .Build(); + + var actual = sp.GetParametersSpan()[0]; + + Assert.Equal("@Amount", actual.ParameterName); + Assert.Equal(SqlDbType.Decimal, actual.SqlDbType); + Assert.Equal(12.50m, actual.Value); + Assert.Equal(18, actual.Precision); + Assert.Equal(4, actual.Scale); + } + + [Fact] + public void AddGeneratedParameter_With_Configured_SqlParameter_Converts_Null_Value_To_DBNull() + { + var parameter = new SqlParameter("@Value", SqlDbType.NVarChar); + + var sp = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, parameter) + .MarkGeneratedParametersBound() + .Build(); + + Assert.Same(DBNull.Value, sp.GetParametersSpan()[0].Value); + } + + [Fact] + public void AddGeneratedTvpParameter_Preserves_Structured_TypeName_And_Streams_Rows() + { + var rows = new[] + { + new TestGeneratedTvpRow(1), + new TestGeneratedTvpRow(2) + }; + + var sp = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedTvpParameter( + 1, + "@Ids", + "dbo.UserIdList", + [new SqlMetaData("UserId", SqlDbType.Int)], + rows, + static (record, row) => record.SetInt32(0, row.UserId)) + .MarkGeneratedParametersBound() + .Build(); + + var parameter = sp.GetParametersSpan()[0]; + var records = Assert.IsAssignableFrom>(parameter.Value); + var values = records.Select(record => record.GetInt32(0)).ToArray(); + + Assert.Equal(SqlDbType.Structured, parameter.SqlDbType); + Assert.Equal("dbo.UserIdList", parameter.TypeName); + Assert.Equal([1, 2], values); + } + + [Fact] + public void AddGeneratedTvpParameter_Empty_Metadata_Throws() + { + var builder = StoredProcedureParametersBuilder + .Create(16); + + var exception = Assert.Throws(() => + builder.AddGeneratedTvpParameter( + 1, + "@Ids", + "dbo.UserIdList", + [], + ReadOnlyMemory.Empty, + static (_, _) => { })); + + Assert.Equal("metadata", exception.ParamName); + } + + [Fact] + public void AddGeneratedParameter_Out_Of_Order_Throws() + { + var builder = StoredProcedureParametersBuilder + .Create(16); + + Assert.Throws(() => + builder.AddGeneratedParameter(2, "@Id", 42, SqlDbType.Int)); + } + + [Fact] + public void AddGeneratedParameter_Ordinal_Zero_Throws() + { + var builder = StoredProcedureParametersBuilder + .Create(16); + + var exception = Assert.Throws(() => + builder.AddGeneratedParameter(0, "@Id", 42, SqlDbType.Int)); + + Assert.Contains("1-based", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddGeneratedParameter_Beyond_Declared_Count_Throws() + { + var builder = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, "@Id", 42, SqlDbType.Int); + + var exception = Assert.Throws(() => + builder.AddGeneratedParameter(2, "@Extra", 43, SqlDbType.Int)); + + Assert.Contains("expects only 1 generated parameter(s)", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void MarkGeneratedParametersBound_With_Missing_Parameters_Throws_With_Counts() + { + var builder = StoredProcedureParametersBuilder + .Create(16) + .AddGeneratedParameter(1, "@Name", "ari", SqlDbType.NVarChar, 64); + + var exception = Assert.Throws(() => + builder.MarkGeneratedParametersBound()); + + Assert.Contains("expects 2 generated parameter(s) but 1 were bound", exception.Message, + StringComparison.Ordinal); + } + + private readonly record struct TestGeneratedTvpRow(int UserId); + + private sealed record TestScalarParameters(int Id) + : ICaeriusGeneratedProcedureParameters + { + public static void Bind( + StoredProcedureParametersBuilder builder, + TestScalarParameters parameters) + { + builder + .AddGeneratedParameter(1, "@Id", parameters.Id, SqlDbType.Int) + .MarkGeneratedParametersBound(); + } + } + + private readonly struct TestNoParameterProcedure : ICaeriusGeneratedProcedure + { + public static string SchemaName => "dbo"; + public static string ProcedureName => "usp_NoParameters"; + public static string FullName => "dbo.usp_NoParameters"; + public static string ContractHash => "sha256:test"; + public static int ParameterCount => 0; + public static int ResultSetCount => 1; + } + + private readonly struct TestScalarProcedure : ICaeriusGeneratedProcedure + { + public static string SchemaName => "dbo"; + public static string ProcedureName => "usp_ById"; + public static string FullName => "dbo.usp_ById"; + public static string ContractHash => "sha256:test"; + public static int ParameterCount => 1; + public static int ResultSetCount => 1; + } + + private readonly struct TestFacetProcedure : ICaeriusGeneratedProcedure + { + public static string SchemaName => "dbo"; + public static string ProcedureName => "usp_ByFacets"; + public static string FullName => "dbo.usp_ByFacets"; + public static string ContractHash => "sha256:test"; + public static int ParameterCount => 2; + public static int ResultSetCount => 1; + } + + private readonly struct TestTvpProcedure : ICaeriusGeneratedProcedure + { + public static string SchemaName => "dbo"; + public static string ProcedureName => "usp_ByIds"; + public static string FullName => "dbo.usp_ByIds"; + public static string ContractHash => "sha256:test"; + public static int ParameterCount => 1; + public static int ResultSetCount => 1; + } +} diff --git a/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs b/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs index 35605f3..1ded16d 100644 --- a/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs @@ -105,10 +105,10 @@ public void StoreRange_Then_TryGet_AllValues_Found() FrozenCacheManager.StoreRange(entries); - Assert.True(FrozenCacheManager.TryGet(key1, out var v1)); + Assert.True(FrozenCacheManager.TryGet(key1, out var valueOne)); Assert.True(FrozenCacheManager.TryGet(key2, out var v2)); Assert.True(FrozenCacheManager.TryGet(key3, out var v3)); - Assert.Equal("value1", v1); + Assert.Equal("value1", valueOne); Assert.Equal("value2", v2); Assert.Equal("value3", v3); } diff --git a/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs b/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs index 772297c..ce3cfcd 100644 --- a/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json.Serialization; namespace CaeriusNet.Tests.Caches; @@ -37,6 +38,22 @@ public void TryGet_Deserializes_Stored_Bytes_Correctly() Assert.Equal(99, result.Value); } + [Fact] + public void JsonTypeInfo_Overloads_Serialize_And_Deserialize() + { + var fake = new FakeDistributedCache(); + var manager = new RedisCacheManager(fake); + var original = new TestPayload("source-generated", 123); + var jsonTypeInfo = RedisCacheManagerTestsJsonContext.Default.TestPayload; + + manager.Store("key-json-type-info", original, null, jsonTypeInfo); + var found = manager.TryGet("key-json-type-info", jsonTypeInfo, out var result); + + Assert.True(found); + Assert.NotNull(result); + Assert.Equal(original, result); + } + [Fact] public void TryGet_Returns_False_When_Key_Not_Found() { @@ -198,5 +215,8 @@ public Task RemoveAsync(string key, CancellationToken token = default) /// /// Simple record used as serialization target in Redis cache tests. /// - private sealed record TestPayload(string Name, int Value); -} \ No newline at end of file + internal sealed record TestPayload(string Name, int Value); +} + +[JsonSerializable(typeof(RedisCacheManagerTests.TestPayload))] +internal sealed partial class RedisCacheManagerTestsJsonContext : JsonSerializerContext; From 6b869c8625a52373bd2a0de6876bcd840d2a49e3 Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 18:44:31 +0200 Subject: [PATCH 2/8] feat(autocontracts): add sql discovery tooling --- Analyzer/AnalyzerReleases.Unshipped.md | 27 +- .../AutoContractsManifestAnalyzer.cs | 225 ++++++++++ .../AutoContractsManifestModels.cs | 72 +++ .../AutoContractsManifestParser.cs | 394 ++++++++++++++++ .../AutoContracts/AutoContractsSqlTypes.cs | 31 ++ Analyzer/CaeriusNet.Analyzer.csproj | 8 +- Analyzer/Diagnostics/DiagnosticDescriptors.cs | 110 ++++- Analyzer/Helpers/IsExternalInit.cs | 3 + CaeriusNet.slnx | 10 +- .../AutoContractsDiagnosticDescriptorTests.cs | 150 +++++++ .../AutoContractsManifestAnalyzerTests.cs | 168 +++++++ .../CaeriusNet.Analyzer.Tests.csproj | 4 + .../CaeriusNet.Analyzer.Tests/GlobalUsings.cs | 2 + .../Utilities/AnalyzerTestHelper.cs | 72 ++- .../Sql/schema.sql | 211 ++++++++- .../Tests/AutoContractsCliTests.cs | 220 +++++++++ .../Tests/AutoContractsMetadataTests.cs | 375 ++++++++++++++++ .../AutoContractsPackagingTests.cs | 303 +++++++++++++ .../CaeriusNet.Packaging.Tests.csproj | 25 ++ .../CSharpName.cs | 31 ++ .../CaeriusNet.SqlServer.Contracts.csproj | 51 +++ .../CommandLineOptions.cs | 55 +++ .../ContractDiagnostics.cs | 44 ++ .../ContractHasher.cs | 105 +++++ .../Contracts.cs | 64 +++ .../CaeriusNet.SqlServer.Contracts/Program.cs | 164 +++++++ .../CaeriusNet.SqlServer.Contracts/README.md | 7 + .../SqlServerContractDiscoverer.cs | 423 ++++++++++++++++++ .../SqlServerTypeMapper.cs | 59 +++ .../SqlServerTypeName.cs | 43 ++ .../CaeriusNet.SqlServer.Contracts.props | 20 + .../CaeriusNet.SqlServer.Contracts.targets | 39 ++ 32 files changed, 3496 insertions(+), 19 deletions(-) create mode 100644 Analyzer/AutoContracts/AutoContractsManifestAnalyzer.cs create mode 100644 Analyzer/AutoContracts/AutoContractsManifestModels.cs create mode 100644 Analyzer/AutoContracts/AutoContractsManifestParser.cs create mode 100644 Analyzer/AutoContracts/AutoContractsSqlTypes.cs create mode 100644 Analyzer/Helpers/IsExternalInit.cs create mode 100644 Tests/CaeriusNet.Analyzer.Tests/AutoContractsDiagnosticDescriptorTests.cs create mode 100644 Tests/CaeriusNet.Analyzer.Tests/AutoContractsManifestAnalyzerTests.cs create mode 100644 Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs create mode 100644 Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs create mode 100644 Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs create mode 100644 Tests/CaeriusNet.Packaging.Tests/CaeriusNet.Packaging.Tests.csproj create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/Program.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/README.md create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets diff --git a/Analyzer/AnalyzerReleases.Unshipped.md b/Analyzer/AnalyzerReleases.Unshipped.md index 1305eb2..b809cbe 100644 --- a/Analyzer/AnalyzerReleases.Unshipped.md +++ b/Analyzer/AnalyzerReleases.Unshipped.md @@ -1,10 +1,21 @@ ### New Rules - Rule ID | Category | Severity | Notes -------------|----------------------|----------|---------------------------------------------------------- - CAERIUS001 | CaeriusNet.Generator | Error | Type must be sealed. - CAERIUS002 | CaeriusNet.Generator | Error | Type must be partial. - CAERIUS003 | CaeriusNet.Generator | Error | Type must declare a primary constructor with parameters. - CAERIUS004 | CaeriusNet.Generator | Error | [GenerateTvp] requires a non-empty TvpName. - CAERIUS005 | CaeriusNet.Generator | Warning | Parameter type falls back to sql_variant. - CAERIUS006 | CaeriusNet.Generator | Error | Generator target must be a non-generic top-level type. + Rule ID | Category | Severity | Notes +------------|---------------------|----------|------------------------------------------------------------------------ + CAERIUS001 | CaeriusNet.Analyzer | Error | Type must be sealed. + CAERIUS002 | CaeriusNet.Analyzer | Error | Type must be partial. + CAERIUS003 | CaeriusNet.Analyzer | Error | Type must declare a primary constructor with parameters. + CAERIUS004 | CaeriusNet.Analyzer | Error | [GenerateTvp] requires a non-empty TvpName. + CAERIUS005 | CaeriusNet.Analyzer | Warning | Parameter type falls back to sql_variant. + CAERIUS006 | CaeriusNet.Analyzer | Error | Generator target must be a non-generic top-level type. + CAERIUS200 | CaeriusNet.Analyzer | Error | Contract manifest must be present when contract generation is enabled. + CAERIUS201 | CaeriusNet.Analyzer | Error | Procedure must be present in the contract manifest. + CAERIUS202 | CaeriusNet.Analyzer | Error | Referenced TVP must be present in the contract manifest. + CAERIUS203 | CaeriusNet.Analyzer | Error | TVP column SQL type must be supported. + CAERIUS204 | CaeriusNet.Analyzer | Error | First result set must be determinable. + CAERIUS205 | CaeriusNet.Analyzer | Warning | Procedure has no result set. + CAERIUS206 | CaeriusNet.Analyzer | Error | Output parameters are not supported by generated contracts. + CAERIUS207 | CaeriusNet.Analyzer | Error | SQL type must be mappable to generated C# code. + CAERIUS208 | CaeriusNet.Analyzer | Warning | Nullable result column is emitted as nullable CLR type. + CAERIUS209 | CaeriusNet.Analyzer | Error | Contract hash mismatch detected in verify mode. + CAERIUS210 | CaeriusNet.Analyzer | Warning | Procedure may be incompatible with generated contracts. diff --git a/Analyzer/AutoContracts/AutoContractsManifestAnalyzer.cs b/Analyzer/AutoContracts/AutoContractsManifestAnalyzer.cs new file mode 100644 index 0000000..bc3cc65 --- /dev/null +++ b/Analyzer/AutoContracts/AutoContractsManifestAnalyzer.cs @@ -0,0 +1,225 @@ +using System.Collections.Generic; +using System.IO; + +namespace CaeriusNet.Analyzer.AutoContracts; + +/// +/// Reports user-facing diagnostics for the SQL Server contracts manifest consumed by AutoContracts generation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AutoContractsManifestAnalyzer : DiagnosticAnalyzer +{ + private const string ManifestFileName = "caerius.contracts.json"; + + public override ImmutableArray SupportedDiagnostics { get; } = + [ + DiagnosticDescriptors.ContractManifestMissing, + DiagnosticDescriptors.ContractProcedureMissing, + DiagnosticDescriptors.ContractTableTypeMissing, + DiagnosticDescriptors.ContractUnsupportedTableTypeSqlType, + DiagnosticDescriptors.ContractUndeterminedResultSet, + DiagnosticDescriptors.ContractNoResultSet, + DiagnosticDescriptors.ContractOutputParameterUnsupported, + DiagnosticDescriptors.ContractUnsupportedSqlType, + DiagnosticDescriptors.ContractNullableResultColumn, + DiagnosticDescriptors.ContractHashMismatch, + DiagnosticDescriptors.ContractPossibleIncompatibility + ]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + private static void AnalyzeCompilation(CompilationAnalysisContext context) + { + var manifestFound = false; + foreach (var additionalFile in context.Options.AdditionalFiles) + { + if (!string.Equals( + Path.GetFileName(additionalFile.Path), + ManifestFileName, + StringComparison.OrdinalIgnoreCase)) + continue; + + manifestFound = true; + AnalyzeManifest(context, additionalFile); + } + + if (!manifestFound && RequiresManifest(context)) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractManifestMissing, + Location.None, + ManifestFileName)); + } + + private static bool RequiresManifest(CompilationAnalysisContext context) + { + if (!context.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + "build_property.CaeriusContractsMode", + out var mode)) + return false; + + return mode.Equals("Pull", StringComparison.OrdinalIgnoreCase) || + mode.Equals("Verify", StringComparison.OrdinalIgnoreCase); + } + + private static void AnalyzeManifest(CompilationAnalysisContext context, AdditionalText manifestFile) + { + var sourceText = manifestFile.GetText(context.CancellationToken); + var source = sourceText?.ToString(); + if (string.IsNullOrWhiteSpace(source)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractManifestMissing, + Location.None, + ManifestFileName)); + return; + } + + AutoContractsManifest manifest; + try + { + manifest = AutoContractsManifestParser.Parse(source!); + } + catch (Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractPossibleIncompatibility, + Location.None, + ManifestFileName, + ex.Message)); + return; + } + + ValidateManifest(context, manifest); + } + + private static void ValidateManifest(CompilationAnalysisContext context, AutoContractsManifest manifest) + { + var tableTypes = BuildTableTypeLookup(manifest); + + foreach (var tableType in manifest.TableTypes.Items) + ValidateTableType(context, tableType); + + foreach (var procedure in manifest.Procedures.Items) + ValidateProcedure(context, procedure, tableTypes); + } + + private static HashSet BuildTableTypeLookup(AutoContractsManifest manifest) + { + var tableTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var tableType in manifest.TableTypes.Items) + tableTypes.Add(BuildFullName(tableType.Schema, tableType.Name)); + + return tableTypes; + } + + private static void ValidateTableType(CompilationAnalysisContext context, AutoContractsTableType tableType) + { + foreach (var column in tableType.Columns.Items) + { + if (AutoContractsSqlTypes.IsSupported(column.SqlType)) + continue; + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractUnsupportedTableTypeSqlType, + Location.None, + BuildFullName(tableType.Schema, tableType.Name, column.Name), + column.SqlType)); + } + } + + private static void ValidateProcedure( + CompilationAnalysisContext context, + AutoContractsProcedure procedure, + HashSet tableTypes) + { + foreach (var parameter in procedure.Parameters.Items) + ValidateParameter(context, procedure, parameter, tableTypes); + + foreach (var column in procedure.ResultSet.Columns.Items) + ValidateResultColumn(context, procedure, column); + + ValidateResultSetStatus(context, procedure); + } + + private static void ValidateParameter( + CompilationAnalysisContext context, + AutoContractsProcedure procedure, + AutoContractsParameter parameter, + HashSet tableTypes) + { + if (parameter.IsOutput) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractOutputParameterUnsupported, + Location.None, + BuildFullName(procedure.Schema, procedure.Name), + parameter.Name)); + + if (parameter.IsTableType) + { + if (!tableTypes.Contains(parameter.SqlType)) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractTableTypeMissing, + Location.None, + parameter.SqlType)); + + return; + } + + if (!AutoContractsSqlTypes.IsSupported(parameter.SqlType)) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractUnsupportedSqlType, + Location.None, + parameter.SqlType)); + } + + private static void ValidateResultColumn( + CompilationAnalysisContext context, + AutoContractsProcedure procedure, + AutoContractsColumn column) + { + if (!AutoContractsSqlTypes.IsSupported(column.SqlType)) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractUnsupportedSqlType, + Location.None, + column.SqlType)); + + if (column.Nullable) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractNullableResultColumn, + Location.None, + BuildFullName(procedure.Schema, procedure.Name, column.Name))); + } + + private static void ValidateResultSetStatus(CompilationAnalysisContext context, AutoContractsProcedure procedure) + { + if (string.Equals(procedure.ResultSet.Status, "Undetermined", StringComparison.OrdinalIgnoreCase)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractUndeterminedResultSet, + Location.None, + BuildFullName(procedure.Schema, procedure.Name))); + return; + } + + if (string.Equals(procedure.ResultSet.Status, "None", StringComparison.OrdinalIgnoreCase)) + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ContractNoResultSet, + Location.None, + BuildFullName(procedure.Schema, procedure.Name))); + } + + private static string BuildFullName(string schema, string name) + { + return schema + "." + name; + } + + private static string BuildFullName(string schema, string name, string member) + { + return schema + "." + name + "." + member; + } +} diff --git a/Analyzer/AutoContracts/AutoContractsManifestModels.cs b/Analyzer/AutoContracts/AutoContractsManifestModels.cs new file mode 100644 index 0000000..c9b6423 --- /dev/null +++ b/Analyzer/AutoContracts/AutoContractsManifestModels.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace CaeriusNet.Analyzer.AutoContracts; + +internal sealed record AutoContractsManifest( + EquatableArray TableTypes, + EquatableArray Procedures); + +internal sealed record AutoContractsTableType( + string Schema, + string Name, + EquatableArray Columns); + +internal sealed record AutoContractsProcedure( + string Schema, + string Name, + EquatableArray Parameters, + AutoContractsResultSet ResultSet); + +internal sealed record AutoContractsParameter( + string Name, + string SqlType, + bool IsTableType, + bool IsOutput); + +internal sealed record AutoContractsResultSet( + string Status, + EquatableArray Columns); + +internal sealed record AutoContractsColumn( + string Name, + string SqlType, + bool Nullable); + +internal readonly struct EquatableArray(ImmutableArray items) : IEquatable> + where T : IEquatable +{ + internal ImmutableArray Items { get; } = items.IsDefault ? ImmutableArray.Empty : items; + + internal static EquatableArray Empty => new(ImmutableArray.Empty); + + public bool Equals(EquatableArray other) + { + if (Items.Length != other.Items.Length) + return false; + + for (var i = 0; i < Items.Length; i++) + if (!EqualityComparer.Default.Equals(Items[i], other.Items[i])) + return false; + + return true; + } + + public override bool Equals(object? obj) + { + return obj is EquatableArray other && Equals(other); + } + + public override int GetHashCode() + { + var hash = 17; + foreach (var item in Items) + hash = hash * 31 + EqualityComparer.Default.GetHashCode(item); + + return hash; + } + + public static implicit operator ImmutableArray(EquatableArray array) + { + return array.Items; + } +} \ No newline at end of file diff --git a/Analyzer/AutoContracts/AutoContractsManifestParser.cs b/Analyzer/AutoContracts/AutoContractsManifestParser.cs new file mode 100644 index 0000000..b8bcbc1 --- /dev/null +++ b/Analyzer/AutoContracts/AutoContractsManifestParser.cs @@ -0,0 +1,394 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace CaeriusNet.Analyzer.AutoContracts; + +internal static class AutoContractsManifestParser +{ + internal static AutoContractsManifest Parse(string source) + { + var root = JsonParser.Parse(source).AsObject(); + return new AutoContractsManifest( + ParseTableTypes(root.GetArray("tableTypes")), + ParseProcedures(root.GetArray("procedures"))); + } + + private static EquatableArray ParseTableTypes(IReadOnlyList values) + { + var tableTypes = ImmutableArray.CreateBuilder(values.Count); + foreach (var value in values) + { + var obj = value.AsObject(); + tableTypes.Add(new AutoContractsTableType( + obj.GetRequiredString("schema"), + obj.GetRequiredString("name"), + ParseColumns(obj.GetArray("columns")))); + } + + return new EquatableArray(tableTypes.MoveToImmutable()); + } + + private static EquatableArray ParseProcedures(IReadOnlyList values) + { + var procedures = ImmutableArray.CreateBuilder(values.Count); + foreach (var value in values) + { + var obj = value.AsObject(); + procedures.Add(new AutoContractsProcedure( + obj.GetRequiredString("schema"), + obj.GetRequiredString("name"), + ParseParameters(obj.GetArray("parameters")), + obj.TryGetObject("resultSet", out var resultSet) + ? ParseResultSet(resultSet) + : new AutoContractsResultSet("None", EquatableArray.Empty))); + } + + return new EquatableArray(procedures.MoveToImmutable()); + } + + private static AutoContractsResultSet ParseResultSet(JsonObject value) + { + return new AutoContractsResultSet( + value.GetString("status", "None"), + ParseColumns(value.GetArray("columns"))); + } + + private static EquatableArray ParseParameters(IReadOnlyList values) + { + var parameters = ImmutableArray.CreateBuilder(values.Count); + foreach (var value in values) + { + var obj = value.AsObject(); + parameters.Add(new AutoContractsParameter( + obj.GetRequiredString("name"), + obj.GetRequiredString("sqlType"), + obj.GetBoolean("isTableType", false), + obj.GetBoolean("isOutput", false))); + } + + return new EquatableArray(parameters.MoveToImmutable()); + } + + private static EquatableArray ParseColumns(IReadOnlyList values) + { + var columns = ImmutableArray.CreateBuilder(values.Count); + foreach (var value in values) + { + var obj = value.AsObject(); + columns.Add(new AutoContractsColumn( + obj.GetRequiredString("name"), + obj.GetRequiredString("sqlType"), + obj.GetBoolean("nullable", false))); + } + + return new EquatableArray(columns.MoveToImmutable()); + } + + private sealed class JsonParser(string text) + { + private int _position; + + private bool IsEnd => _position >= text.Length; + + internal static JsonValue Parse(string text) + { + var parser = new JsonParser(text); + var value = parser.ParseValue(); + parser.SkipWhiteSpace(); + if (!parser.IsEnd) + throw new FormatException("Unexpected trailing content in contract manifest."); + + return value; + } + + private JsonValue ParseValue() + { + SkipWhiteSpace(); + if (IsEnd) + throw new FormatException("Unexpected end of JSON."); + + return text[_position] switch + { + '{' => ParseObject(), + '[' => ParseArray(), + '"' => new JsonValue(ParseString()), + 't' => ParseLiteral("true", new JsonValue(true)), + 'f' => ParseLiteral("false", new JsonValue(false)), + 'n' => ParseLiteral("null", JsonValue.Null), + '-' or >= '0' and <= '9' => new JsonValue(ParseNumber()), + _ => throw new FormatException($"Unexpected character '{text[_position]}' in JSON.") + }; + } + + private JsonValue ParseObject() + { + Expect('{'); + var values = new Dictionary(StringComparer.Ordinal); + SkipWhiteSpace(); + if (TryConsume('}')) + return new JsonValue(new JsonObject(values)); + + while (true) + { + SkipWhiteSpace(); + var name = ParseString(); + SkipWhiteSpace(); + Expect(':'); + values[name] = ParseValue(); + SkipWhiteSpace(); + if (TryConsume('}')) + return new JsonValue(new JsonObject(values)); + + Expect(','); + } + } + + private JsonValue ParseArray() + { + Expect('['); + var values = new List(); + SkipWhiteSpace(); + if (TryConsume(']')) + return new JsonValue(values); + + while (true) + { + values.Add(ParseValue()); + SkipWhiteSpace(); + if (TryConsume(']')) + return new JsonValue(values); + + Expect(','); + } + } + + private string ParseString() + { + Expect('"'); + var sb = new StringBuilder(); + while (!IsEnd) + { + var ch = text[_position++]; + if (ch == '"') + return sb.ToString(); + + if (ch != '\\') + { + sb.Append(ch); + continue; + } + + if (IsEnd) + throw new FormatException("Unterminated JSON escape sequence."); + + var escaped = text[_position++]; + switch (escaped) + { + case '"': + case '\\': + case '/': + sb.Append(escaped); + break; + case 'b': + sb.Append('\b'); + break; + case 'f': + sb.Append('\f'); + break; + case 'n': + sb.Append('\n'); + break; + case 'r': + sb.Append('\r'); + break; + case 't': + sb.Append('\t'); + break; + case 'u': + sb.Append(ParseUnicodeEscape()); + break; + default: + throw new FormatException($"Unsupported JSON escape sequence '\\{escaped}'."); + } + } + + throw new FormatException("Unterminated JSON string."); + } + + private char ParseUnicodeEscape() + { + if (_position + 4 > text.Length) + throw new FormatException("Invalid JSON unicode escape."); + + var value = Convert.ToInt32(text.Substring(_position, 4), 16); + _position += 4; + return (char)value; + } + + private double ParseNumber() + { + var start = _position; + if (text[_position] == '-') + _position++; + + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + + if (!IsEnd && text[_position] == '.') + { + _position++; + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + } + + if (!IsEnd && (text[_position] == 'e' || text[_position] == 'E')) + { + _position++; + if (!IsEnd && (text[_position] == '+' || text[_position] == '-')) + _position++; + while (!IsEnd && char.IsDigit(text[_position])) + _position++; + } + + return double.Parse(text.Substring(start, _position - start), CultureInfo.InvariantCulture); + } + + private JsonValue ParseLiteral(string literal, JsonValue value) + { + if (_position + literal.Length > text.Length || + !string.Equals(text.Substring(_position, literal.Length), literal, StringComparison.Ordinal)) + throw new FormatException($"Invalid JSON literal at position {_position}."); + + _position += literal.Length; + return value; + } + + private void SkipWhiteSpace() + { + while (!IsEnd && char.IsWhiteSpace(text[_position])) + _position++; + } + + private bool TryConsume(char expected) + { + if (IsEnd || text[_position] != expected) + return false; + + _position++; + return true; + } + + private void Expect(char expected) + { + if (!TryConsume(expected)) + throw new FormatException($"Expected '{expected}' at position {_position}."); + } + } + + private sealed class JsonValue + { + internal static readonly JsonValue Null = new(); + + private readonly object? _value; + + private JsonValue() + { + } + + internal JsonValue(string value) + { + _value = value; + } + + internal JsonValue(double value) + { + _value = value; + } + + internal JsonValue(bool value) + { + _value = value; + } + + internal JsonValue(JsonObject value) + { + _value = value; + } + + internal JsonValue(IReadOnlyList value) + { + _value = value; + } + + internal bool IsNull => _value is null; + + internal JsonObject AsObject() + { + return _value as JsonObject + ?? throw new FormatException("Expected JSON object."); + } + + internal IReadOnlyList AsArray() + { + return _value as IReadOnlyList + ?? throw new FormatException("Expected JSON array."); + } + + internal string AsString() + { + return _value as string + ?? throw new FormatException("Expected JSON string."); + } + + internal bool AsBoolean() + { + return _value is bool value + ? value + : throw new FormatException("Expected JSON boolean."); + } + } + + private sealed class JsonObject(Dictionary values) + { + internal string GetRequiredString(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsString() + : throw new FormatException($"Required manifest property '{name}' is missing."); + } + + internal string GetString(string name, string defaultValue) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsString() + : defaultValue; + } + + internal bool GetBoolean(string name, bool defaultValue) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsBoolean() + : defaultValue; + } + + internal IReadOnlyList GetArray(string name) + { + return values.TryGetValue(name, out var value) && !value.IsNull + ? value.AsArray() + : []; + } + + internal bool TryGetObject(string name, out JsonObject obj) + { + if (values.TryGetValue(name, out var value) && !value.IsNull) + { + obj = value.AsObject(); + return true; + } + + obj = null!; + return false; + } + } +} \ No newline at end of file diff --git a/Analyzer/AutoContracts/AutoContractsSqlTypes.cs b/Analyzer/AutoContracts/AutoContractsSqlTypes.cs new file mode 100644 index 0000000..963d910 --- /dev/null +++ b/Analyzer/AutoContracts/AutoContractsSqlTypes.cs @@ -0,0 +1,31 @@ +namespace CaeriusNet.Analyzer.AutoContracts; + +internal static class AutoContractsSqlTypes +{ + internal static bool IsSupported(string sqlType) + { + return Normalize(sqlType) switch + { + "bit" or "tinyint" or "smallint" or "int" or "bigint" or + "real" or "float" or "decimal" or "numeric" or "money" or "smallmoney" or + "date" or "time" or "datetime" or "datetime2" or "smalldatetime" or "datetimeoffset" or + "uniqueidentifier" or "nvarchar" or "varchar" or "nchar" or "char" or + "varbinary" or "binary" => true, + _ => false + }; + } + + internal static string Normalize(string sqlType) + { + var span = sqlType.AsSpan().Trim(); + var paren = span.IndexOf('('); + if (paren >= 0) + span = span.Slice(0, paren); + + var dot = span.LastIndexOf('.'); + if (dot >= 0) + span = span.Slice(dot + 1); + + return span.ToString().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/Analyzer/CaeriusNet.Analyzer.csproj b/Analyzer/CaeriusNet.Analyzer.csproj index 955a0d6..ee58587 100644 --- a/Analyzer/CaeriusNet.Analyzer.csproj +++ b/Analyzer/CaeriusNet.Analyzer.csproj @@ -1,4 +1,5 @@ - + netstandard2.0 @@ -11,6 +12,11 @@ CaeriusNet.Analyzer CaeriusNet.Analyzer + false + false + false + false + false diff --git a/Analyzer/Diagnostics/DiagnosticDescriptors.cs b/Analyzer/Diagnostics/DiagnosticDescriptors.cs index 2a8a47e..a9c4e31 100644 --- a/Analyzer/Diagnostics/DiagnosticDescriptors.cs +++ b/Analyzer/Diagnostics/DiagnosticDescriptors.cs @@ -5,7 +5,7 @@ namespace CaeriusNet.Analyzer.Diagnostics; /// internal static class DiagnosticDescriptors { - private const string Category = "CaeriusNet.Generator"; + private const string Category = "CaeriusNet.Analyzer"; private const string HelpLinkBase = "https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/"; @@ -69,4 +69,112 @@ internal static class DiagnosticDescriptors true, "CaeriusNet generators emit companion partial declarations and currently support only non-generic top-level types.", HelpLinkBase + "CAERIUS006.md"); + + internal static readonly DiagnosticDescriptor ContractManifestMissing = CreateCompilationEnd( + "CAERIUS200", + "Contract manifest is missing", + "The CaeriusNet contract manifest '{0}' was not found", + DiagnosticSeverity.Error, + "Generated SQL Server contracts require a caerius.contracts.json manifest supplied as an AdditionalFiles item.", + HelpLinkBase + "CAERIUS200.md"); + + internal static readonly DiagnosticDescriptor ContractProcedureMissing = CreateCompilationEnd( + "CAERIUS201", + "Procedure is missing from contract manifest", + "Procedure '{0}' is missing from the CaeriusNet contract manifest", + DiagnosticSeverity.Error, + "The generated procedure descriptor must come from the read-only SQL Server contract manifest.", + HelpLinkBase + "CAERIUS201.md"); + + internal static readonly DiagnosticDescriptor ContractTableTypeMissing = CreateCompilationEnd( + "CAERIUS202", + "TVP is missing from contract manifest", + "TVP '{0}' is referenced but is missing from the CaeriusNet contract manifest", + DiagnosticSeverity.Error, + "Every generated structured parameter must reference a table type present in the contract manifest.", + HelpLinkBase + "CAERIUS202.md"); + + internal static readonly DiagnosticDescriptor ContractUnsupportedTableTypeSqlType = CreateCompilationEnd( + "CAERIUS203", + "TVP SQL type is not supported", + "TVP column '{0}' uses unsupported SQL type '{1}'", + DiagnosticSeverity.Error, + "Generated TVP metadata must preserve SQL Server facets and can only emit supported SqlMetaData mappings.", + HelpLinkBase + "CAERIUS203.md"); + + internal static readonly DiagnosticDescriptor ContractUndeterminedResultSet = CreateCompilationEnd( + "CAERIUS204", + "First result set cannot be determined", + "The first result set of procedure '{0}' cannot be determined by SQL Server", + DiagnosticSeverity.Error, + "Automatic DTO generation requires SQL Server to describe the first result set from metadata.", + HelpLinkBase + "CAERIUS204.md"); + + internal static readonly DiagnosticDescriptor ContractNoResultSet = CreateCompilationEnd( + "CAERIUS205", + "Procedure has no result set", + "Procedure '{0}' has no result set; no result DTO will be generated", + DiagnosticSeverity.Warning, + "Procedures without a first result set can still get descriptors and parameter builders.", + HelpLinkBase + "CAERIUS205.md"); + + internal static readonly DiagnosticDescriptor ContractOutputParameterUnsupported = CreateCompilationEnd( + "CAERIUS206", + "Output parameter is not supported", + "Procedure '{0}' has output parameter '{1}', which is not supported by generated contracts", + DiagnosticSeverity.Error, + "Generated contracts support input parameters only.", + HelpLinkBase + "CAERIUS206.md"); + + internal static readonly DiagnosticDescriptor ContractUnsupportedSqlType = CreateCompilationEnd( + "CAERIUS207", + "SQL type is not mappable", + "SQL type '{0}' cannot be mapped to generated C# code", + DiagnosticSeverity.Error, + "Unsupported SQL Server types must be handled explicitly instead of falling back to runtime reflection or dynamic mapping.", + HelpLinkBase + "CAERIUS207.md"); + + internal static readonly DiagnosticDescriptor ContractNullableResultColumn = CreateCompilationEnd( + "CAERIUS208", + "Nullable result column", + "Result column '{0}' is nullable and will be emitted as a nullable CLR type", + DiagnosticSeverity.Warning, + "Nullable SQL Server result columns are represented with nullable CLR types unless a stricter project policy rejects them.", + HelpLinkBase + "CAERIUS208.md"); + + internal static readonly DiagnosticDescriptor ContractHashMismatch = CreateCompilationEnd( + "CAERIUS209", + "Contract hash mismatch", + "Contract hash mismatch for '{0}'", + DiagnosticSeverity.Error, + "Verify mode compares read-only SQL Server metadata with the manifest hash to catch schema drift.", + HelpLinkBase + "CAERIUS209.md"); + + internal static readonly DiagnosticDescriptor ContractPossibleIncompatibility = CreateCompilationEnd( + "CAERIUS210", + "Procedure may be incompatible with generated contracts", + "Procedure '{0}' may be incompatible with generated contracts: {1}", + DiagnosticSeverity.Warning, + "SQL Server metadata discovery can be conservative for dynamic SQL, temporary tables, ambiguous projections, or unsupported constructs.", + HelpLinkBase + "CAERIUS210.md"); + + private static DiagnosticDescriptor CreateCompilationEnd( + string id, + string title, + string messageFormat, + DiagnosticSeverity severity, + string description, + string helpLinkUri) + { + return new DiagnosticDescriptor( + id, + title, + messageFormat, + Category, + severity, + true, + description, + helpLinkUri, + WellKnownDiagnosticTags.CompilationEnd); + } } \ No newline at end of file diff --git a/Analyzer/Helpers/IsExternalInit.cs b/Analyzer/Helpers/IsExternalInit.cs new file mode 100644 index 0000000..15d1327 --- /dev/null +++ b/Analyzer/Helpers/IsExternalInit.cs @@ -0,0 +1,3 @@ +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit; \ No newline at end of file diff --git a/CaeriusNet.slnx b/CaeriusNet.slnx index eedc57c..b63641c 100644 --- a/CaeriusNet.slnx +++ b/CaeriusNet.slnx @@ -19,8 +19,10 @@ - + + + @@ -32,5 +34,9 @@ + + + + - \ No newline at end of file + diff --git a/Tests/CaeriusNet.Analyzer.Tests/AutoContractsDiagnosticDescriptorTests.cs b/Tests/CaeriusNet.Analyzer.Tests/AutoContractsDiagnosticDescriptorTests.cs new file mode 100644 index 0000000..e79f3af --- /dev/null +++ b/Tests/CaeriusNet.Analyzer.Tests/AutoContractsDiagnosticDescriptorTests.cs @@ -0,0 +1,150 @@ +using System.Reflection; + +namespace CaeriusNet.Analyzer.Tests; + +public sealed class AutoContractsDiagnosticDescriptorTests +{ + private const string Category = "CaeriusNet.Analyzer"; + + private const string HelpLinkBase = + "https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/"; + + private static readonly IReadOnlyDictionary ExpectedDiagnostics = + new Dictionary(StringComparer.Ordinal) + { + ["CAERIUS200"] = new( + DiagnosticSeverity.Error, + "Contract manifest is missing", + "The CaeriusNet contract manifest '{0}' was not found"), + ["CAERIUS201"] = new( + DiagnosticSeverity.Error, + "Procedure is missing from contract manifest", + "Procedure '{0}' is missing from the CaeriusNet contract manifest"), + ["CAERIUS202"] = new( + DiagnosticSeverity.Error, + "TVP is missing from contract manifest", + "TVP '{0}' is referenced but is missing from the CaeriusNet contract manifest"), + ["CAERIUS203"] = new( + DiagnosticSeverity.Error, + "TVP SQL type is not supported", + "TVP column '{0}' uses unsupported SQL type '{1}'"), + ["CAERIUS204"] = new( + DiagnosticSeverity.Error, + "First result set cannot be determined", + "The first result set of procedure '{0}' cannot be determined by SQL Server"), + ["CAERIUS205"] = new( + DiagnosticSeverity.Warning, + "Procedure has no result set", + "Procedure '{0}' has no result set; no result DTO will be generated"), + ["CAERIUS206"] = new( + DiagnosticSeverity.Error, + "Output parameter is not supported", + "Procedure '{0}' has output parameter '{1}', which is not supported by generated contracts"), + ["CAERIUS207"] = new( + DiagnosticSeverity.Error, + "SQL type is not mappable", + "SQL type '{0}' cannot be mapped to generated C# code"), + ["CAERIUS208"] = new( + DiagnosticSeverity.Warning, + "Nullable result column", + "Result column '{0}' is nullable and will be emitted as a nullable CLR type"), + ["CAERIUS209"] = new( + DiagnosticSeverity.Error, + "Contract hash mismatch", + "Contract hash mismatch for '{0}'"), + ["CAERIUS210"] = new( + DiagnosticSeverity.Warning, + "Procedure may be incompatible with generated contracts", + "Procedure '{0}' may be incompatible with generated contracts: {1}") + }; + + [Fact] + public void SupportedDiagnostics_Advertise_AutoContractsRules() + { + var supportedIds = new AutoContractsManifestAnalyzer() + .SupportedDiagnostics + .Select(diagnostic => diagnostic.Id) + .ToHashSet(StringComparer.Ordinal); + + foreach (var id in ExpectedDiagnostics.Keys) + Assert.Contains(id, supportedIds); + } + + [Fact] + public void AutoContractsDescriptors_MatchExpectedMetadata() + { + var descriptors = GetAnalyzerDescriptors(); + + foreach (var (id, expected) in ExpectedDiagnostics) + { + var descriptor = Assert.Single(descriptors, descriptor => descriptor.Id == id); + + Assert.Equal(expected.Severity, descriptor.DefaultSeverity); + Assert.Equal(expected.Title, descriptor.Title.ToString()); + Assert.Equal(expected.MessageFormat, descriptor.MessageFormat.ToString()); + Assert.Equal(Category, descriptor.Category); + Assert.True(descriptor.IsEnabledByDefault); + Assert.Equal(HelpLinkBase + id + ".md", descriptor.HelpLinkUri); + } + } + + [Fact] + public void AutoContractsDescriptors_DoNotExposeVersionedNames() + { + var descriptors = GetAnalyzerDescriptors() + .Where(descriptor => ExpectedDiagnostics.ContainsKey(descriptor.Id)); + + foreach (var descriptor in descriptors) + { + var publicText = string.Join( + Environment.NewLine, + descriptor.Title, + descriptor.MessageFormat, + descriptor.Description); + + Assert.DoesNotContain("V" + "1", publicText, StringComparison.Ordinal); + Assert.DoesNotContain("v" + "1", publicText, StringComparison.Ordinal); + } + } + + [Fact] + public void AnalyzerReleaseNotes_ContainAutoContractsRulesWithExpectedSeverities() + { + var releaseNotes = File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "AnalyzerReleases.Unshipped.md")); + var releaseRows = releaseNotes + .Select(ParseReleaseRow) + .Where(row => row is not null) + .Select(row => row!.Value) + .ToDictionary(row => row.Id, row => row.Severity, StringComparer.Ordinal); + + foreach (var (id, expected) in ExpectedDiagnostics) + Assert.Equal(expected.Severity.ToString(), releaseRows[id]); + } + + private static IReadOnlyList GetAnalyzerDescriptors() + { + var descriptorsType = typeof(GeneratorUsageAnalyzer).Assembly.GetType( + "CaeriusNet.Analyzer.Diagnostics.DiagnosticDescriptors", + true)!; + + return descriptorsType + .GetFields(BindingFlags.NonPublic | BindingFlags.Static) + .Where(field => field.FieldType == typeof(DiagnosticDescriptor)) + .Select(field => (DiagnosticDescriptor)field.GetValue(null)!) + .ToArray(); + } + + private static (string Id, string Severity)? ParseReleaseRow(string line) + { + var parts = line.Split('|', StringSplitOptions.TrimEntries); + if (parts.Length < 3 || !parts[0].StartsWith("CAERIUS", StringComparison.Ordinal)) + return null; + + return (parts[0], parts[2]); + } + + private sealed record ExpectedDiagnostic( + DiagnosticSeverity Severity, + string Title, + string MessageFormat); +} diff --git a/Tests/CaeriusNet.Analyzer.Tests/AutoContractsManifestAnalyzerTests.cs b/Tests/CaeriusNet.Analyzer.Tests/AutoContractsManifestAnalyzerTests.cs new file mode 100644 index 0000000..316e899 --- /dev/null +++ b/Tests/CaeriusNet.Analyzer.Tests/AutoContractsManifestAnalyzerTests.cs @@ -0,0 +1,168 @@ +namespace CaeriusNet.Analyzer.Tests; + +public sealed class AutoContractsManifestAnalyzerTests +{ + [Fact] + public void EmptyManifest_Reports_CAERIUS200() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + Manifest("")); + + AssertDiagnostic(diagnostics, "CAERIUS200", DiagnosticSeverity.Error); + } + + [Fact] + public void PullModeWithoutManifest_Reports_CAERIUS200() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + new Dictionary(StringComparer.Ordinal) + { + ["build_property.CaeriusContractsMode"] = "Pull" + }); + + AssertDiagnostic(diagnostics, "CAERIUS200", DiagnosticSeverity.Error); + } + + [Fact] + public void UndeterminedResultSet_Reports_CAERIUS204() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + Manifest(""" + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [], + "procedures": [ + { + "schema": "dbo", + "name": "SomeProcedure", + "clrName": "SomeProcedure", + "parametersClrName": "SomeProcedureParameters", + "resultClrName": "SomeProcedureResult", + "parameters": [], + "resultSet": { "status": "Undetermined", "columns": [] }, + "contractHash": "sha256:test" + } + ] + } + """)); + + AssertDiagnostic(diagnostics, "CAERIUS204", DiagnosticSeverity.Error); + } + + [Fact] + public void InvalidManifest_Reports_CAERIUS210_FromAnalyzer() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + Manifest("{")); + + AssertDiagnostic(diagnostics, "CAERIUS210", DiagnosticSeverity.Warning); + } + + [Fact] + public void NoResultSet_Reports_CAERIUS205() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + Manifest(""" + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [], + "procedures": [ + { + "schema": "dbo", + "name": "NoRows", + "clrName": "NoRowsProcedure", + "parametersClrName": "NoRowsParameters", + "parameters": [], + "resultSet": { "status": "None", "columns": [] }, + "contractHash": "sha256:test" + } + ] + } + """)); + + AssertDiagnostic(diagnostics, "CAERIUS205", DiagnosticSeverity.Warning); + } + + [Fact] + public void ContractShapeProblems_ReportExpectedDiagnostics() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer( + "namespace Consumer;", + Manifest(""" + { + "version": 1, + "namespace": "Consumer.Contracts", + "tableTypes": [ + { + "schema": "dbo", + "name": "InputRows", + "clrName": "InputRowsTvp", + "columns": [ + { "ordinal": 1, "name": "Payload", "sqlType": "xml", "clrType": "string", "nullable": false } + ], + "contractHash": "sha256:table" + } + ], + "procedures": [ + { + "schema": "dbo", + "name": "Search", + "clrName": "SearchProcedure", + "parametersClrName": "SearchParameters", + "resultClrName": "SearchResult", + "parameters": [ + { "ordinal": 1, "name": "Rows", "sqlType": "dbo.MissingRows", "clrType": "ReadOnlyMemory", "isTableType": true }, + { "ordinal": 2, "name": "Legacy", "sqlType": "sql_variant", "clrType": "object", "isTableType": false }, + { "ordinal": 3, "name": "OutId", "sqlType": "int", "clrType": "int", "isOutput": true } + ], + "resultSet": { + "status": "Available", + "columns": [ + { "ordinal": 1, "name": "UserId", "sqlType": "int", "clrType": "int", "nullable": true }, + { "ordinal": 2, "name": "Legacy", "sqlType": "cursor", "clrType": "object", "nullable": false } + ] + }, + "contractHash": "sha256:procedure" + } + ] + } + """)); + + AssertDiagnostic(diagnostics, "CAERIUS202", DiagnosticSeverity.Error); + AssertDiagnostic(diagnostics, "CAERIUS203", DiagnosticSeverity.Error); + AssertDiagnostic(diagnostics, "CAERIUS206", DiagnosticSeverity.Error); + AssertDiagnostic(diagnostics, "CAERIUS207", DiagnosticSeverity.Error); + AssertDiagnostic(diagnostics, "CAERIUS208", DiagnosticSeverity.Warning); + } + + [Fact] + public void NoManifestAdditionalFile_Reports_NoAutoContractsDiagnostics() + { + var diagnostics = AnalyzerTestHelper.RunAnalyzer("namespace Consumer;"); + + Assert.DoesNotContain(diagnostics, + diagnostic => diagnostic.Id.StartsWith("CAERIUS2", StringComparison.Ordinal)); + } + + private static TestAdditionalText Manifest(string json) + { + return new TestAdditionalText("caerius.contracts.json", json); + } + + private static void AssertDiagnostic( + IEnumerable diagnostics, + string id, + DiagnosticSeverity severity) + { + Assert.Contains( + diagnostics, + diagnostic => diagnostic.Id == id && diagnostic.Severity == severity); + } +} diff --git a/Tests/CaeriusNet.Analyzer.Tests/CaeriusNet.Analyzer.Tests.csproj b/Tests/CaeriusNet.Analyzer.Tests/CaeriusNet.Analyzer.Tests.csproj index 68d7fc3..85210ef 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/CaeriusNet.Analyzer.Tests.csproj +++ b/Tests/CaeriusNet.Analyzer.Tests/CaeriusNet.Analyzer.Tests.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs index 3bd8197..66acd72 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs @@ -2,8 +2,10 @@ global using System.Collections.Immutable; global using System.Collections.Generic; global using System.Linq; +global using CaeriusNet.Analyzer.AutoContracts; global using CaeriusNet.Analyzer.Tests.Utilities; global using Microsoft.CodeAnalysis; global using Microsoft.CodeAnalysis.CSharp; global using Microsoft.CodeAnalysis.Diagnostics; +global using Microsoft.CodeAnalysis.Text; global using Xunit; \ No newline at end of file diff --git a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs index 742d5a1..3e93f23 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace CaeriusNet.Analyzer.Tests.Utilities; internal static class AnalyzerTestHelper @@ -25,21 +27,38 @@ public sealed class GenerateTvpAttribute : Attribute } """; - internal static IReadOnlyList RunAnalyzer(string source) + internal static IReadOnlyList RunAnalyzer(string source, params AdditionalText[] additionalTexts) + { + return RunAnalyzer(source, globalOptions: null, additionalTexts); + } + + internal static IReadOnlyList RunAnalyzer( + string source, + IReadOnlyDictionary? globalOptions, + params AdditionalText[] additionalTexts) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest); var attributeTree = CSharpSyntaxTree.ParseText(AttributeDefinitions, parseOptions); var sourceTree = CSharpSyntaxTree.ParseText(source, parseOptions); var compilation = CreateCompilation(attributeTree, sourceTree); + var analyzerOptions = new AnalyzerOptions( + additionalTexts.ToImmutableArray(), + new TestAnalyzerConfigOptionsProvider(globalOptions)); var diagnostics = compilation.WithAnalyzers( - ImmutableArray.Create(new GeneratorUsageAnalyzer())) + ImmutableArray.Create( + new GeneratorUsageAnalyzer(), + new AutoContractsManifestAnalyzer()), + analyzerOptions) .GetAnalyzerDiagnosticsAsync() .GetAwaiter() .GetResult(); return diagnostics - .Where(diagnostic => ReferenceEquals(diagnostic.Location.SourceTree, sourceTree)) + .Where(diagnostic => + additionalTexts.Length > 0 || + globalOptions is not null || + ReferenceEquals(diagnostic.Location.SourceTree, sourceTree)) .OrderBy(diagnostic => diagnostic.Location.SourceSpan.Start) .ThenBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) .ToArray(); @@ -82,4 +101,49 @@ private static IReadOnlyList BuildMetadataReferences() return references; } -} \ No newline at end of file +} + +internal sealed class TestAnalyzerConfigOptionsProvider( + IReadOnlyDictionary? globalOptions) : AnalyzerConfigOptionsProvider +{ + private static readonly AnalyzerConfigOptions EmptyOptions = new TestAnalyzerConfigOptions(null); + + public override AnalyzerConfigOptions GlobalOptions { get; } = + new TestAnalyzerConfigOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return EmptyOptions; + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return EmptyOptions; + } +} + +internal sealed class TestAnalyzerConfigOptions( + IReadOnlyDictionary? options) : AnalyzerConfigOptions +{ + public override bool TryGetValue(string key, out string value) + { + if (options is not null && options.TryGetValue(key, out var found)) + { + value = found; + return true; + } + + value = string.Empty; + return false; + } +} + +internal sealed class TestAdditionalText(string path, string text) : AdditionalText +{ + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return SourceText.From(text, Encoding.UTF8); + } +} diff --git a/Tests/CaeriusNet.IntegrationTests/Sql/schema.sql b/Tests/CaeriusNet.IntegrationTests/Sql/schema.sql index 98a5288..3c38e64 100644 --- a/Tests/CaeriusNet.IntegrationTests/Sql/schema.sql +++ b/Tests/CaeriusNet.IntegrationTests/Sql/schema.sql @@ -2,13 +2,19 @@ -- All objects live in [dbo] for simplicity; tests truncate Widgets between runs to stay -- order-deterministic across the xunit non-parallel collection. -IF -OBJECT_ID(N'dbo.WidgetTvp', N'TT') IS NOT NULL -DROP TYPE dbo.WidgetTvp; IF OBJECT_ID(N'dbo.usp_InsertWidget', N'P') IS NOT NULL DROP PROCEDURE dbo.usp_InsertWidget; IF +OBJECT_ID(N'contracts.usp_SearchWidgets', N'P') IS NOT NULL +DROP PROCEDURE contracts.usp_SearchWidgets; +IF +OBJECT_ID(N'contracts.usp_PreviewWidgetBatch', N'P') IS NOT NULL +DROP PROCEDURE contracts.usp_PreviewWidgetBatch; +IF +OBJECT_ID(N'contracts.usp_QuoteWidget', N'P') IS NOT NULL +DROP PROCEDURE contracts.usp_QuoteWidget; +IF OBJECT_ID(N'dbo.usp_BulkInsertWidgets', N'P') IS NOT NULL DROP PROCEDURE dbo.usp_BulkInsertWidgets; IF @@ -42,6 +48,33 @@ IF OBJECT_ID(N'dbo.usp_GetWidgetsFourSets', N'P') IS NOT NULL DROP PROCEDURE dbo.usp_GetWidgetsFourSets; IF +OBJECT_ID(N'dbo.usp_AutoContracts_SearchWidgets', N'P') IS NOT NULL +DROP PROCEDURE dbo.usp_AutoContracts_SearchWidgets; +IF +OBJECT_ID(N'dbo.usp_AutoContracts_GetWidgetById', N'P') IS NOT NULL +DROP PROCEDURE dbo.usp_AutoContracts_GetWidgetById; +IF +OBJECT_ID(N'dbo.usp_AutoContracts_PreviewWidgetBatch', N'P') IS NOT NULL +DROP PROCEDURE dbo.usp_AutoContracts_PreviewWidgetBatch; +IF +OBJECT_ID(N'dbo.usp_AutoContracts_QuoteWidget', N'P') IS NOT NULL +DROP PROCEDURE dbo.usp_AutoContracts_QuoteWidget; +IF +OBJECT_ID(N'dbo.AutoContractsWidgetProjection', N'V') IS NOT NULL +DROP VIEW dbo.AutoContractsWidgetProjection; +IF +TYPE_ID(N'dbo.WidgetTvp') IS NOT NULL +DROP TYPE dbo.WidgetTvp; +IF +TYPE_ID(N'dbo.AutoContractsWidgetTvp') IS NOT NULL +DROP TYPE dbo.AutoContractsWidgetTvp; +IF +TYPE_ID(N'contracts.WidgetTvp') IS NOT NULL +DROP TYPE contracts.WidgetTvp; +IF +OBJECT_ID(N'dbo.AutoContractsExecutionProbe', N'U') IS NOT NULL +DROP TABLE dbo.AutoContractsExecutionProbe; +IF OBJECT_ID(N'dbo.Widgets', N'U') IS NOT NULL DROP TABLE dbo.Widgets; @@ -61,6 +94,104 @@ CREATE TYPE dbo.WidgetTvp AS TABLE ); GO +CREATE TYPE dbo.AutoContractsWidgetTvp AS TABLE + ( + ExternalId UNIQUEIDENTIFIER NOT NULL, + Name NVARCHAR(100) NOT NULL, + Quantity INT NOT NULL, + UnitPrice DECIMAL (18, 4) NOT NULL, + EffectiveDate DATE NULL, + ChangedAt DATETIMEOFFSET(3) NULL + ); +GO + +CREATE TABLE dbo.AutoContractsExecutionProbe +( + ExecutionCount INT NOT NULL CONSTRAINT DF_AutoContractsExecutionProbe_ExecutionCount DEFAULT 0 +); +GO + +INSERT INTO dbo.AutoContractsExecutionProbe (ExecutionCount) +VALUES (0); +GO + +IF SCHEMA_ID(N'contracts') IS NULL + EXEC(N'CREATE SCHEMA contracts'); +GO + +-- Read-only projection used by AutoContracts metadata probes. It exists only to expose +-- stable result-set metadata; no integration test depends on persisted rows here. +CREATE VIEW dbo.AutoContractsWidgetProjection +AS +SELECT Id, + Name, + Quantity, + CreatedAt +FROM dbo.Widgets; +GO + +CREATE TYPE contracts.WidgetTvp AS TABLE + ( + ExternalId UNIQUEIDENTIFIER NOT NULL, + Name NVARCHAR(100) NOT NULL, + Quantity INT NOT NULL, + UnitPrice DECIMAL (18, 4) NOT NULL, + EffectiveDate DATE NULL, + ChangedAt DATETIMEOFFSET(3) NULL + ); +GO + +CREATE PROCEDURE contracts.usp_SearchWidgets @NamePrefix NVARCHAR(100) = NULL, + @MinimumQuantity INT = NULL, + @CreatedAfter DATETIME2(3) = NULL +AS +BEGIN + SET +NOCOUNT ON; +SELECT Id, Name, Quantity, CreatedAt +FROM dbo.AutoContractsWidgetProjection +WHERE (@NamePrefix IS NULL OR Name LIKE @NamePrefix + N'%') + AND (@MinimumQuantity IS NULL OR Quantity >= @MinimumQuantity) + AND (@CreatedAfter IS NULL OR CreatedAt >= @CreatedAfter) + AND 1 = 0 +ORDER BY Id; +END; +GO + +CREATE PROCEDURE contracts.usp_PreviewWidgetBatch @Items contracts.WidgetTvp READONLY +AS +BEGIN + SET +NOCOUNT ON; +SELECT ExternalId, + Name, + Quantity, + UnitPrice, + EffectiveDate, + ChangedAt +FROM @Items +WHERE 1 = 0 +ORDER BY Name; +END; +GO + +CREATE PROCEDURE contracts.usp_QuoteWidget @Name NVARCHAR(75), + @Quantity INT, + @UnitPrice DECIMAL(18, 4), + @RequestedAt DATETIME2(2) = NULL +AS +BEGIN + SET +NOCOUNT ON; +UPDATE dbo.AutoContractsExecutionProbe +SET ExecutionCount = ExecutionCount + 1; +SELECT @Name AS Name, + @Quantity AS Quantity, + @UnitPrice AS UnitPrice, + @RequestedAt AS RequestedAt WHERE 1 = 0; +END; +GO + CREATE PROCEDURE dbo.usp_InsertWidget @Name NVARCHAR(100), @Quantity INT AS @@ -214,3 +345,77 @@ FROM dbo.Widgets ORDER BY Id DESC; END; GO + +-- AutoContracts read-only metadata fixture: scalar parameters + deterministic DTO shape. +CREATE PROCEDURE dbo.usp_AutoContracts_SearchWidgets @NamePrefix NVARCHAR(100) = NULL, + @MinimumQuantity INT = NULL, + @CreatedAfter DATETIME2(3) = NULL +AS +BEGIN + SET +NOCOUNT ON; +SELECT Id, Name, Quantity, CreatedAt +FROM dbo.AutoContractsWidgetProjection +WHERE (@NamePrefix IS NULL OR Name LIKE @NamePrefix + N'%') + AND (@MinimumQuantity IS NULL OR Quantity >= @MinimumQuantity) + AND (@CreatedAfter IS NULL OR CreatedAt >= @CreatedAfter) + AND 1 = 0 +ORDER BY Id; +END; +GO + +-- AutoContracts read-only metadata fixture: required scalar input with a nullable switch. +CREATE PROCEDURE dbo.usp_AutoContracts_GetWidgetById @Id INT, + @IncludeArchived BIT = 0 +AS +BEGIN + SET +NOCOUNT ON; +SELECT Id, Name, Quantity, CreatedAt +FROM dbo.AutoContractsWidgetProjection +WHERE Id = @Id + AND @IncludeArchived IN (0, 1) + AND 1 = 0; +END; +GO + +-- AutoContracts read-only metadata fixture: table-valued input and its projected shape. +CREATE PROCEDURE dbo.usp_AutoContracts_PreviewWidgetBatch @Items dbo.AutoContractsWidgetTvp READONLY +AS +BEGIN + SET +NOCOUNT ON; +SELECT ExternalId, + Name, + Quantity, + UnitPrice, + EffectiveDate, + ChangedAt +FROM @Items +WHERE 1 = 0 +ORDER BY Name; +END; +GO + +-- AutoContracts metadata fixture: scalar facets, output parameter detection, and an +-- execution probe. Metadata discovery must not run this body. +CREATE PROCEDURE dbo.usp_AutoContracts_QuoteWidget @Name NVARCHAR(75), + @Quantity INT, + @UnitPrice DECIMAL(18, 4), + @RequestedAt DATETIME2(2) = NULL, + @QuoteTotal DECIMAL(19, 4) OUTPUT +AS +BEGIN + SET +NOCOUNT ON; +UPDATE dbo.AutoContractsExecutionProbe +SET ExecutionCount = ExecutionCount + 1; +SET +@QuoteTotal = @Quantity * @UnitPrice; +SELECT @Name AS Name, + @Quantity AS Quantity, + @UnitPrice AS UnitPrice, + @RequestedAt AS RequestedAt, + @QuoteTotal AS QuoteTotal WHERE 1 = 0; +END; +GO diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs new file mode 100644 index 0000000..eccce88 --- /dev/null +++ b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs @@ -0,0 +1,220 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CaeriusNet.IntegrationTests.Tests; + +/// +/// Exercises the SQL Server metadata CLI against the Docker-backed integration database. +/// +[Collection(SqlServerCollection.Name)] +public sealed class AutoContractsCliTests(SqlServerFixture fixture) +{ + [SqlServerAvailableFact] + public async Task Cli_Pull_And_Verify_Generates_Manifest_From_ReadOnly_Metadata() + { + await SetExecutionProbeAsync(0); + + var repoRoot = FindRepoRoot(); + var toolProject = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", + "CaeriusNet.SqlServer.Contracts.csproj"); + var manifestPath = Path.Combine(Path.GetTempPath(), "caerius-contracts-" + Guid.NewGuid().ToString("N"), + "caerius.contracts.json"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + + try + { + var pull = await RunDotnetAsync( + repoRoot, + "run", + "--project", + toolProject, + "--", + "pull", + "--connection-string", + fixture.ConnectionString, + "--schemas", + "contracts", + "--output", + manifestPath); + + Assert.True(pull.ExitCode == 0, pull.ToString()); + Assert.True(File.Exists(manifestPath), $"Expected manifest to be written at {manifestPath}."); + + await AssertManifestShapeAsync(manifestPath); + + var verify = await RunDotnetAsync( + repoRoot, + "run", + "--project", + toolProject, + "--", + "verify", + "--connection-string", + fixture.ConnectionString, + "--schemas", + "contracts", + "--manifest", + manifestPath); + + Assert.True(verify.ExitCode == 0, verify.ToString()); + Assert.Equal(0, await ReadExecutionProbeAsync()); + } + finally + { + var directory = Path.GetDirectoryName(manifestPath); + if (directory is not null && Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } + + [SqlServerAvailableFact] + public async Task Cli_Verify_Reports_Missing_Procedure_And_Drift() + { + var repoRoot = FindRepoRoot(); + var toolProject = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", + "CaeriusNet.SqlServer.Contracts.csproj"); + var manifestPath = Path.Combine(Path.GetTempPath(), "caerius-contracts-" + Guid.NewGuid().ToString("N"), + "caerius.contracts.json"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + await File.WriteAllTextAsync(manifestPath, """ + { + "version": 1, + "namespace": "CaeriusNet.Generated", + "database": { + "name": "missing", + "serverVersion": "missing", + "compatibilityLevel": 0 + }, + "tableTypes": [], + "procedures": [] + } + """); + + try + { + var verify = await RunDotnetAsync( + repoRoot, + "run", + "--project", + toolProject, + "--", + "verify", + "--connection-string", + fixture.ConnectionString, + "--schemas", + "contracts", + "--manifest", + manifestPath); + + Assert.Equal(2, verify.ExitCode); + Assert.Contains("CAERIUS201 error", verify.StandardError, StringComparison.Ordinal); + Assert.Contains("CAERIUS209 error", verify.StandardError, StringComparison.Ordinal); + } + finally + { + var directory = Path.GetDirectoryName(manifestPath); + if (directory is not null && Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } + + private static async Task AssertManifestShapeAsync(string manifestPath) + { + await using var stream = File.OpenRead(manifestPath); + using var document = await JsonDocument.ParseAsync(stream); + var root = document.RootElement; + + Assert.Equal(1, root.GetProperty("version").GetInt32()); + + var tableType = Assert.Single(root.GetProperty("tableTypes").EnumerateArray()); + Assert.Equal("contracts", tableType.GetProperty("schema").GetString()); + Assert.Equal("WidgetTvp", tableType.GetProperty("name").GetString()); + Assert.Equal("decimal(18,4)", tableType.GetProperty("columns")[3].GetProperty("sqlType").GetString()); + Assert.Equal(18, tableType.GetProperty("columns")[3].GetProperty("precision").GetInt32()); + Assert.Equal(4, tableType.GetProperty("columns")[3].GetProperty("scale").GetInt32()); + + var procedures = root.GetProperty("procedures").EnumerateArray().ToArray(); + Assert.Contains(procedures, procedure => + procedure.GetProperty("schema").GetString() == "contracts" && + procedure.GetProperty("name").GetString() == "usp_SearchWidgets"); + Assert.Contains(procedures, procedure => + procedure.GetProperty("schema").GetString() == "contracts" && + procedure.GetProperty("name").GetString() == "usp_PreviewWidgetBatch"); + Assert.Contains(procedures, procedure => + procedure.GetProperty("schema").GetString() == "contracts" && + procedure.GetProperty("name").GetString() == "usp_QuoteWidget"); + } + + private async Task SetExecutionProbeAsync(int value) + { + await using var connection = new SqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "UPDATE dbo.AutoContractsExecutionProbe SET ExecutionCount = @Value;"; + command.Parameters.Add(new SqlParameter("@Value", SqlDbType.Int) { Value = value }); + await command.ExecuteNonQueryAsync(); + } + + private async Task ReadExecutionProbeAsync() + { + await using var connection = new SqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT ExecutionCount FROM dbo.AutoContractsExecutionProbe;"; + var value = await command.ExecuteScalarAsync(); + return Assert.IsType(value); + } + + private static async Task RunDotnetAsync(string workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = workingDirectory, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CommandResult( + process.ExitCode, + await standardOutput, + await standardError); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CaeriusNet.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "Tools", "CaeriusNet.SqlServer.Contracts"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } + + private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError) + { + public override string ToString() + { + return $""" + Exit code: {ExitCode} + Standard output: + {StandardOutput} + Standard error: + {StandardError} + """; + } + } +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs new file mode 100644 index 0000000..aefc088 --- /dev/null +++ b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs @@ -0,0 +1,375 @@ +using DotNet.Testcontainers.Builders; + +namespace CaeriusNet.IntegrationTests.Tests; + +internal sealed class SqlServerAvailableFactAttribute : FactAttribute +{ + public SqlServerAvailableFactAttribute() + { + SqlServerFixture? fixture = null; + try + { + fixture = new SqlServerFixture(); + } + catch (DockerUnavailableException ex) + { + Skip = CreateSkipReason(ex); + } + catch (DockerConfigurationException ex) + { + Skip = CreateSkipReason(ex); + } + catch (TimeoutException ex) + { + Skip = CreateSkipReason(ex); + } + finally + { + if (fixture is not null) + fixture.DisposeAsync().GetAwaiter().GetResult(); + } + } + + private static string CreateSkipReason(Exception exception) + { + return $"SQL Server AutoContracts metadata test skipped because SQL Server is unavailable: {exception.Message}"; + } +} + +/// +/// Verifies the SQL-only metadata fixtures intended for AutoContracts discovery. +/// +[Collection(SqlServerCollection.Name)] +public sealed class AutoContractsMetadataTests(SqlServerFixture fixture) +{ + [SqlServerAvailableFact] + public async Task Sql_Metadata_Exposes_AutoContracts_Read_Shapes() + { + await using var connection = new SqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + + await ResetExecutionProbeAsync(connection); + var executionCountBeforeMetadata = await ReadExecutionProbeCountAsync(connection); + + var searchColumns = + await ReadFirstResultSetColumnsAsync(connection, "dbo.usp_AutoContracts_SearchWidgets"); + AssertSequentialOrdinals(searchColumns); + Assert.Collection(searchColumns, + column => AssertResultColumn(column, 1, "Id", "int", false), + column => AssertResultColumn(column, 2, "Name", "nvarchar(100)", false), + column => AssertResultColumn(column, 3, "Quantity", "int", false), + column => AssertResultColumn(column, 4, "CreatedAt", "datetime2(3)", false)); + + var byIdColumns = + await ReadFirstResultSetColumnsAsync(connection, "dbo.usp_AutoContracts_GetWidgetById"); + AssertSequentialOrdinals(byIdColumns); + Assert.Collection(byIdColumns, + column => AssertResultColumn(column, 1, "Id", "int", false), + column => AssertResultColumn(column, 2, "Name", "nvarchar(100)", false), + column => AssertResultColumn(column, 3, "Quantity", "int", false), + column => AssertResultColumn(column, 4, "CreatedAt", "datetime2(3)", false)); + + var batchColumns = + await ReadFirstResultSetColumnsAsync(connection, "dbo.usp_AutoContracts_PreviewWidgetBatch"); + AssertSequentialOrdinals(batchColumns); + Assert.Collection(batchColumns, + column => AssertResultColumn(column, 1, "ExternalId", "uniqueidentifier", false), + column => AssertResultColumn(column, 2, "Name", "nvarchar(100)", false), + column => AssertResultColumn(column, 3, "Quantity", "int", false), + column => AssertResultColumn(column, 4, "UnitPrice", "decimal(18,4)", false), + column => AssertResultColumn(column, 5, "EffectiveDate", "date", true), + column => AssertResultColumn(column, 6, "ChangedAt", "datetimeoffset(3)", true)); + + var quoteColumns = + await ReadFirstResultSetColumnsAsync(connection, "dbo.usp_AutoContracts_QuoteWidget"); + AssertSequentialOrdinals(quoteColumns); + Assert.Collection(quoteColumns, + column => AssertResultColumn(column, 1, "Name", "nvarchar(75)", true), + column => AssertResultColumn(column, 2, "Quantity", "int", true), + column => AssertResultColumn(column, 3, "UnitPrice", "decimal(18,4)", true), + column => AssertResultColumn(column, 4, "RequestedAt", "datetime2(2)", true), + column => AssertResultColumn(column, 5, "QuoteTotal", "decimal(19,4)", true)); + + var searchParameters = + await ReadProcedureParametersAsync(connection, "dbo.usp_AutoContracts_SearchWidgets"); + Assert.Collection(searchParameters, + parameter => AssertParameter(parameter, 1, "@NamePrefix", "sys", "nvarchar", false, false, false, 200), + parameter => AssertParameter(parameter, 2, "@MinimumQuantity", "sys", "int", false, false, false), + parameter => + AssertParameter(parameter, 3, "@CreatedAfter", "sys", "datetime2", false, false, false, scale: 3)); + + var byIdParameters = + await ReadProcedureParametersAsync(connection, "dbo.usp_AutoContracts_GetWidgetById"); + Assert.Collection(byIdParameters, + parameter => AssertParameter(parameter, 1, "@Id", "sys", "int", false, false, false), + parameter => AssertParameter(parameter, 2, "@IncludeArchived", "sys", "bit", false, false, false)); + + var batchParameters = + await ReadProcedureParametersAsync(connection, "dbo.usp_AutoContracts_PreviewWidgetBatch"); + var itemsParameter = Assert.Single(batchParameters); + AssertParameter(itemsParameter, 1, "@Items", "dbo", "AutoContractsWidgetTvp", false, true, true); + + var quoteParameters = + await ReadProcedureParametersAsync(connection, "dbo.usp_AutoContracts_QuoteWidget"); + Assert.Collection(quoteParameters, + parameter => AssertParameter(parameter, 1, "@Name", "sys", "nvarchar", false, false, false, 150), + parameter => AssertParameter(parameter, 2, "@Quantity", "sys", "int", false, false, false), + parameter => AssertParameter(parameter, 3, "@UnitPrice", "sys", "decimal", false, false, false, + precision: 18, scale: 4), + parameter => + AssertParameter(parameter, 4, "@RequestedAt", "sys", "datetime2", false, false, false, scale: 2), + parameter => AssertParameter(parameter, 5, "@QuoteTotal", "sys", "decimal", true, false, false, + precision: 19, scale: 4)); + + var tableTypeColumns = await ReadTableTypeColumnsAsync(connection, "dbo", "AutoContractsWidgetTvp"); + Assert.Collection(tableTypeColumns, + column => AssertTableTypeColumn(column, 1, "ExternalId", "uniqueidentifier", false), + column => AssertTableTypeColumn(column, 2, "Name", "nvarchar", false, 200), + column => AssertTableTypeColumn(column, 3, "Quantity", "int", false), + column => AssertTableTypeColumn(column, 4, "UnitPrice", "decimal", false, precision: 18, scale: 4), + column => AssertTableTypeColumn(column, 5, "EffectiveDate", "date", true), + column => AssertTableTypeColumn(column, 6, "ChangedAt", "datetimeoffset", true, scale: 3)); + + var executionCountAfterMetadata = await ReadExecutionProbeCountAsync(connection); + Assert.Equal(executionCountBeforeMetadata, executionCountAfterMetadata); + } + + private static async Task> ReadFirstResultSetColumnsAsync( + SqlConnection connection, + string procedureName) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT column_ordinal, + name, + system_type_name, + is_nullable, + error_number, + error_message + FROM sys.dm_exec_describe_first_result_set_for_object(OBJECT_ID(@ProcedureName), 0) + WHERE is_hidden = 0 OR error_number IS NOT NULL + ORDER BY column_ordinal; + """; + command.Parameters.Add(new SqlParameter("@ProcedureName", SqlDbType.NVarChar, 776) + { + Value = procedureName + }); + + var columns = ImmutableArray.CreateBuilder(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (!reader.IsDBNull(4)) + throw new InvalidOperationException(reader.GetString(5)); + + columns.Add(new ResultSetColumn( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.GetBoolean(3))); + } + + return columns.ToImmutable(); + } + + private static async Task> ReadProcedureParametersAsync( + SqlConnection connection, + string procedureName) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT p.parameter_id, + p.name, + SCHEMA_NAME(t.schema_id) AS TypeSchemaName, + t.name AS TypeName, + p.max_length, + p.precision, + p.scale, + p.is_output, + p.is_readonly, + t.is_table_type + FROM sys.parameters AS p + INNER JOIN sys.types AS t + ON p.user_type_id = t.user_type_id + WHERE p.object_id = OBJECT_ID(@ProcedureName) + ORDER BY p.parameter_id; + """; + command.Parameters.Add(new SqlParameter("@ProcedureName", SqlDbType.NVarChar, 776) + { + Value = procedureName + }); + + var parameters = ImmutableArray.CreateBuilder(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + parameters.Add(new ProcedureParameter( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt16(4), + reader.GetByte(5), + reader.GetByte(6), + reader.GetBoolean(7), + reader.GetBoolean(8), + reader.GetBoolean(9))); + + return parameters.ToImmutable(); + } + + private static async Task> ReadTableTypeColumnsAsync( + SqlConnection connection, + string schemaName, + string typeName) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT c.column_id, + c.name, + st.name AS TypeName, + c.max_length, + c.precision, + c.scale, + c.is_nullable + FROM sys.table_types AS tt + INNER JOIN sys.columns AS c + ON tt.type_table_object_id = c.object_id + INNER JOIN sys.types AS st + ON c.user_type_id = st.user_type_id + WHERE SCHEMA_NAME(tt.schema_id) = @SchemaName + AND tt.name = @TypeName + ORDER BY c.column_id; + """; + command.Parameters.Add(new SqlParameter("@SchemaName", SqlDbType.NVarChar, 128) + { + Value = schemaName + }); + command.Parameters.Add(new SqlParameter("@TypeName", SqlDbType.NVarChar, 128) + { + Value = typeName + }); + + var columns = ImmutableArray.CreateBuilder(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + columns.Add(new TableTypeColumn( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.GetInt16(3), + reader.GetByte(4), + reader.GetByte(5), + reader.GetBoolean(6))); + + return columns.ToImmutable(); + } + + private static async Task ResetExecutionProbeAsync(SqlConnection connection) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE dbo.AutoContractsExecutionProbe + SET ExecutionCount = 0; + """; + await command.ExecuteNonQueryAsync(); + } + + private static async Task ReadExecutionProbeCountAsync(SqlConnection connection) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT ExecutionCount FROM dbo.AutoContractsExecutionProbe;"; + var value = await command.ExecuteScalarAsync(); + return Assert.IsType(value); + } + + private static void AssertSequentialOrdinals(ImmutableArray columns) + { + Assert.Equal(Enumerable.Range(1, columns.Length), columns.Select(column => column.Ordinal)); + } + + private static void AssertResultColumn( + ResultSetColumn column, + int ordinal, + string name, + string systemTypeName, + bool isNullable) + { + Assert.Equal(ordinal, column.Ordinal); + Assert.Equal(name, column.Name); + Assert.Equal(systemTypeName, column.SystemTypeName); + Assert.Equal(isNullable, column.IsNullable); + } + + private static void AssertParameter( + ProcedureParameter parameter, + int ordinal, + string name, + string typeSchemaName, + string typeName, + bool isOutput, + bool isReadonly, + bool isTableType, + short? maxLength = null, + byte? precision = null, + byte? scale = null) + { + Assert.Equal(ordinal, parameter.Ordinal); + Assert.Equal(name, parameter.Name); + Assert.Equal(typeSchemaName, parameter.TypeSchemaName); + Assert.Equal(typeName, parameter.TypeName); + Assert.Equal(isOutput, parameter.IsOutput); + Assert.Equal(isReadonly, parameter.IsReadonly); + Assert.Equal(isTableType, parameter.IsTableType); + if (maxLength is not null) + Assert.Equal(maxLength.Value, parameter.MaxLength); + if (precision is not null) + Assert.Equal(precision.Value, parameter.Precision); + if (scale is not null) + Assert.Equal(scale.Value, parameter.Scale); + } + + private static void AssertTableTypeColumn( + TableTypeColumn column, + int ordinal, + string name, + string typeName, + bool isNullable, + short? maxLength = null, + byte? precision = null, + byte? scale = null) + { + Assert.Equal(ordinal, column.Ordinal); + Assert.Equal(name, column.Name); + Assert.Equal(typeName, column.TypeName); + Assert.Equal(isNullable, column.IsNullable); + if (maxLength is not null) + Assert.Equal(maxLength.Value, column.MaxLength); + if (precision is not null) + Assert.Equal(precision.Value, column.Precision); + if (scale is not null) + Assert.Equal(scale.Value, column.Scale); + } + + private sealed record ResultSetColumn(int Ordinal, string Name, string SystemTypeName, bool IsNullable); + + private sealed record ProcedureParameter( + int Ordinal, + string Name, + string TypeSchemaName, + string TypeName, + short MaxLength, + byte Precision, + byte Scale, + bool IsOutput, + bool IsReadonly, + bool IsTableType); + + private sealed record TableTypeColumn( + int Ordinal, + string Name, + string TypeName, + short MaxLength, + byte Precision, + byte Scale, + bool IsNullable); +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs new file mode 100644 index 0000000..e648090 --- /dev/null +++ b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs @@ -0,0 +1,303 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Security; +using Xunit; + +namespace CaeriusNet.Packaging.Tests; + +public sealed class AutoContractsPackagingTests +{ + [Fact] + public async Task PackageContainsBuildTransitiveImports() + { + var repoRoot = FindRepoRoot(); + var toolSource = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts"); + + using var temp = new TemporaryDirectory(); + var toolCopy = Path.Combine(temp.Path, "tool"); + var packageOutput = Path.Combine(temp.Path, "packages"); + CopyToolProject(toolSource, toolCopy); + Directory.CreateDirectory(packageOutput); + + var result = await RunDotnetAsync( + toolCopy, + "pack", + "CaeriusNet.SqlServer.Contracts.csproj", + "--configuration", + "Release", + "--output", + packageOutput); + + Assert.True(result.ExitCode == 0, result.ToString()); + + var package = Assert.Single(Directory.EnumerateFiles(packageOutput, "CaeriusNet.SqlServer.Contracts.*.nupkg")); + await using var archive = await ZipFile.OpenReadAsync(package); + + Assert.NotNull(archive.GetEntry("buildTransitive/CaeriusNet.SqlServer.Contracts.props")); + Assert.NotNull(archive.GetEntry("buildTransitive/CaeriusNet.SqlServer.Contracts.targets")); + Assert.NotNull(archive.GetEntry("tools/net10.0/any/CaeriusNet.SqlServer.Contracts.dll")); + Assert.NotNull(archive.GetEntry("tools/net10.0/any/CaeriusNet.SqlServer.Contracts.deps.json")); + Assert.NotNull(archive.GetEntry("tools/net10.0/any/CaeriusNet.SqlServer.Contracts.runtimeconfig.json")); + + var nuspec = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal)); + await using var stream = await nuspec.OpenAsync(); + using var reader = new StreamReader(stream); + var nuspecText = await reader.ReadToEndAsync(); + Assert.DoesNotContain("DotnetTool", nuspecText, StringComparison.Ordinal); + Assert.DoesNotContain(" + + net10.0 + Off + + + + + + + + + + + """); + + var buildResult = await RunDotnetAsync( + Path.GetDirectoryName(consumerProject)!, + new Dictionary + { + ["NUGET_PACKAGES"] = packageCache + }, + "build", + consumerProject, + "--configuration", + "Release", + "--source", + packageOutput); + + Assert.True(buildResult.ExitCode == 0, buildResult.ToString()); + + var printResult = await RunDotnetAsync( + Path.GetDirectoryName(consumerProject)!, + new Dictionary + { + ["NUGET_PACKAGES"] = packageCache + }, + "msbuild", + consumerProject, + "/t:WriteContractsToolCommand", + "/p:RestoreSources=" + packageOutput, + "/restore", + "/nologo", + "/v:minimal"); + + Assert.True(printResult.ExitCode == 0, printResult.ToString()); + + var line = Assert.Single(await File.ReadAllLinesAsync(toolCommandOutput)); + var parts = line.Split('|', 2); + Assert.Equal(2, parts.Length); + Assert.True(File.Exists(parts[0]), $"Expected packaged tool path to exist: {parts[0]}"); + Assert.Contains("CaeriusNet.SqlServer.Contracts.dll", parts[1], StringComparison.Ordinal); + } + + [Fact] + public async Task TargetsAddManifestCreatedDuringMsbuildExecution() + { + var repoRoot = FindRepoRoot(); + var props = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", "buildTransitive", + "CaeriusNet.SqlServer.Contracts.props"); + var targets = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", "buildTransitive", + "CaeriusNet.SqlServer.Contracts.targets"); + + using var temp = new TemporaryDirectory(); + var project = Path.Combine(temp.Path, "Smoke.proj"); + var manifest = Path.Combine(temp.Path, "generated.contracts.json"); + var additionalFiles = Path.Combine(temp.Path, "additional-files.txt"); + + await File.WriteAllTextAsync( + project, + $$""" + + + {{Escape(manifest)}} + + + + + + + + + + + + + + """); + + var result = await RunDotnetAsync(temp.Path, "msbuild", project, "/t:RunSmoke", "/nologo", "/v:minimal"); + + Assert.True(result.ExitCode == 0, result.ToString()); + Assert.Contains( + await File.ReadAllLinesAsync(additionalFiles), + line => string.Equals(line, $"{Path.GetFullPath(manifest)}|true", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void AutoContractsMsbuildFilesDoNotUseVersionedNames() + { + var repoRoot = FindRepoRoot(); + var buildTransitive = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", "buildTransitive"); + + foreach (var file in Directory.EnumerateFiles(buildTransitive, "*", SearchOption.AllDirectories)) + { + var text = File.ReadAllText(file); + + Assert.DoesNotContain("V" + "1", text, StringComparison.Ordinal); + Assert.DoesNotContain("v" + "1", text, StringComparison.Ordinal); + } + } + + private static Task RunDotnetAsync(string workingDirectory, params string[] arguments) + { + return RunDotnetAsync(workingDirectory, null, arguments); + } + + private static async Task RunDotnetAsync( + string workingDirectory, + IReadOnlyDictionary? environmentVariables, + params string[] arguments) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = workingDirectory, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false + }; + + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + + if (environmentVariables is not null) + foreach (var (key, value) in environmentVariables) + startInfo.Environment[key] = value; + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CommandResult( + process.ExitCode, + await standardOutput, + await standardError); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "CaeriusNet.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "Tools", "CaeriusNet.SqlServer.Contracts"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } + + private static string Escape(string value) + { + return SecurityElement.Escape(value) ?? value; + } + + private static void CopyToolProject(string source, string destination) + { + Directory.CreateDirectory(destination); + Directory.CreateDirectory(Path.Combine(destination, "buildTransitive")); + + File.Copy( + Path.Combine(source, "CaeriusNet.SqlServer.Contracts.csproj"), + Path.Combine(destination, "CaeriusNet.SqlServer.Contracts.csproj")); + + foreach (var file in Directory.EnumerateFiles(source, "*.cs")) + File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + + foreach (var file in Directory.EnumerateFiles(source, "*.md")) + File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + + foreach (var file in Directory.EnumerateFiles(Path.Combine(source, "buildTransitive"))) + File.Copy(file, Path.Combine(destination, "buildTransitive", Path.GetFileName(file))); + } + + private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError) + { + public override string ToString() + { + return $""" + Exit code: {ExitCode} + Standard output: + {StandardOutput} + Standard error: + {StandardError} + """; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "caerius-packaging-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) Directory.Delete(Path, true); + } + } +} \ No newline at end of file diff --git a/Tests/CaeriusNet.Packaging.Tests/CaeriusNet.Packaging.Tests.csproj b/Tests/CaeriusNet.Packaging.Tests/CaeriusNet.Packaging.Tests.csproj new file mode 100644 index 0000000..ba24fbf --- /dev/null +++ b/Tests/CaeriusNet.Packaging.Tests/CaeriusNet.Packaging.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + latest + enable + enable + false + true + CaeriusNet.Packaging.Tests + true + 4 + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs b/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs new file mode 100644 index 0000000..4172b78 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs @@ -0,0 +1,31 @@ +using System.Text; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class CSharpName +{ + internal static string FromSqlName(string name) + { + var sb = new StringBuilder(name.Length); + var upperNext = true; + foreach (var ch in name) + { + if (!char.IsLetterOrDigit(ch)) + { + upperNext = true; + continue; + } + + sb.Append(upperNext ? char.ToUpperInvariant(ch) : ch); + upperNext = false; + } + + if (sb.Length == 0) + return "SqlContract"; + + if (char.IsDigit(sb[0])) + sb.Insert(0, '_'); + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj new file mode 100644 index 0000000..2b3c83d --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj @@ -0,0 +1,51 @@ + + + + Exe + net10.0 + latest + enable + enable + CaeriusNet.SqlServer.Contracts + CaeriusNet.SqlServer.Contracts + true + false + true + CaeriusNet.SqlServer.Contracts + 11.0.3 + AriusII & CaeriusNet + SQL Server read-only contract discovery tool for CaeriusNet generated stored procedure contracts. + README.md + MIT + https://github.com/CaeriusNET/CaeriusNet + git + caeriusnet;sql-server;stored-procedures;contracts;source-generator + true + $(TargetsForTfmSpecificContentInPackage);CaeriusPackContractsTool + + + + + + + + + + + + + + + + + + + diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs b/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs new file mode 100644 index 0000000..a896253 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs @@ -0,0 +1,55 @@ +namespace CaeriusNet.SqlServer.Contracts; + +internal sealed record CommandLineOptions( + string? ConnectionEnv, + string? ConnectionString, + string[] Schemas, + string? Output, + string? Manifest) +{ + internal static CommandLineOptions Parse(string[] args) + { + string? connectionEnv = null; + string? connectionString = null; + string[] schemas = []; + string? output = null; + string? manifest = null; + + for (var i = 0; i < args.Length; i++) + { + var option = args[i]; + + string Next() + { + if (i + 1 >= args.Length) + throw new ArgumentException($"Missing value for '{option}'."); + + return args[++i]; + } + + switch (option) + { + case "--connection-env": + connectionEnv = Next(); + break; + case "--connection-string": + connectionString = Next(); + break; + case "--schemas": + schemas = Next() + .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + break; + case "--output": + output = Next(); + break; + case "--manifest": + manifest = Next(); + break; + default: + throw new ArgumentException($"Unknown option '{option}'."); + } + } + + return new CommandLineOptions(connectionEnv, connectionString, schemas, output, manifest); + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs b/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs new file mode 100644 index 0000000..bbe8d39 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs @@ -0,0 +1,44 @@ +namespace CaeriusNet.SqlServer.Contracts; + +internal enum ContractDiagnosticSeverity +{ + Warning, + Error +} + +internal sealed record ContractDiagnostic( + string Id, + ContractDiagnosticSeverity Severity, + string Message); + +internal sealed class ContractDiagnosticSink +{ + private readonly List _diagnostics = []; + + internal bool HasErrors => + _diagnostics.Exists(diagnostic => diagnostic.Severity == ContractDiagnosticSeverity.Error); + + internal IReadOnlyList Diagnostics => _diagnostics; + + internal void Error(string id, string message) + { + _diagnostics.Add(new ContractDiagnostic(id, ContractDiagnosticSeverity.Error, message)); + } + + internal void Warning(string id, string message) + { + _diagnostics.Add(new ContractDiagnostic(id, ContractDiagnosticSeverity.Warning, message)); + } +} + +internal static class ContractDiagnosticWriter +{ + internal static void Write(ContractDiagnosticSink sink) + { + foreach (var diagnostic in sink.Diagnostics) + { + var severity = diagnostic.Severity.ToString().ToLowerInvariant(); + Console.Error.WriteLine($"{diagnostic.Id} {severity}: {diagnostic.Message}"); + } + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs b/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs new file mode 100644 index 0000000..b194a7f --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class ContractHasher +{ + internal static string HashTableType( + string schema, + string name, + IReadOnlyList columns) + { + var builder = new StringBuilder(); + AppendValue(builder, "table-type"); + AppendValue(builder, schema); + AppendValue(builder, name); + foreach (var column in columns.OrderBy(column => column.Ordinal)) + AppendColumn(builder, column); + + return Hash(builder); + } + + internal static string HashProcedure( + string schema, + string name, + IReadOnlyList parameters, + ResultSetContract resultSet) + { + var builder = new StringBuilder(); + AppendValue(builder, "procedure"); + AppendValue(builder, schema); + AppendValue(builder, name); + foreach (var parameter in parameters.OrderBy(parameter => parameter.Ordinal)) + AppendParameter(builder, parameter); + + AppendValue(builder, resultSet.Status); + foreach (var column in resultSet.Columns.OrderBy(column => column.Ordinal)) + AppendColumn(builder, column); + + return Hash(builder); + } + + private static string Hash(StringBuilder builder) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static void AppendParameter(StringBuilder builder, ParameterContract parameter) + { + AppendValue(builder, parameter.Ordinal); + AppendValue(builder, parameter.Name); + AppendValue(builder, parameter.SqlType); + AppendValue(builder, parameter.ClrType); + AppendValue(builder, parameter.IsTableType); + AppendValue(builder, parameter.IsOutput); + AppendValue(builder, parameter.Nullable); + AppendValue(builder, parameter.MaxLength); + AppendValue(builder, parameter.Precision); + AppendValue(builder, parameter.Scale); + } + + private static void AppendColumn(StringBuilder builder, ColumnContract column) + { + AppendValue(builder, column.Ordinal); + AppendValue(builder, column.Name); + AppendValue(builder, column.SqlType); + AppendValue(builder, column.ClrType); + AppendValue(builder, column.Nullable); + AppendValue(builder, column.MaxLength); + AppendValue(builder, column.Precision); + AppendValue(builder, column.Scale); + } + + private static void AppendValue(StringBuilder builder, string? value) + { + if (value is null) + { + builder.Append("null;"); + return; + } + + builder + .Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append(':') + .Append(value) + .Append(';'); + } + + private static void AppendValue(StringBuilder builder, int? value) + { + AppendValue(builder, value?.ToString(CultureInfo.InvariantCulture)); + } + + private static void AppendValue(StringBuilder builder, byte? value) + { + AppendValue(builder, value?.ToString(CultureInfo.InvariantCulture)); + } + + private static void AppendValue(StringBuilder builder, bool value) + { + AppendValue(builder, value ? "true" : "false"); + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs b/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs new file mode 100644 index 0000000..ee135fd --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs @@ -0,0 +1,64 @@ +namespace CaeriusNet.SqlServer.Contracts; + +internal sealed record ContractManifest( + int Version, + string Namespace, + DatabaseInfo Database, + IReadOnlyList TableTypes, + IReadOnlyList Procedures); + +internal sealed record DatabaseInfo( + string Name, + string ServerVersion, + int CompatibilityLevel); + +internal sealed record TableTypeContract( + string Schema, + string Name, + string ClrName, + IReadOnlyList Columns, + string ContractHash); + +internal sealed record ProcedureContract( + string Schema, + string Name, + string ClrName, + string ParametersClrName, + string? ResultClrName, + IReadOnlyList Parameters, + ResultSetContract ResultSet, + string ContractHash); + +internal sealed record ParameterContract( + int Ordinal, + string Name, + string SqlType, + string ClrType, + bool IsTableType, + bool IsOutput, + bool Nullable, + int? MaxLength, + byte? Precision, + byte? Scale); + +internal sealed record ResultSetContract( + string Status, + IReadOnlyList Columns, + string? ErrorMessage); + +internal sealed record ColumnContract( + int Ordinal, + string Name, + string SqlType, + string ClrType, + bool Nullable, + int? MaxLength, + byte? Precision, + byte? Scale); + +internal sealed record MutableTableType(string Schema, string Name, int UserTypeId) +{ + public List Columns { get; } = []; +} + +internal sealed record ProcedureHeader(int ObjectId, string Schema, string Name); \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/Program.cs b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs new file mode 100644 index 0000000..bb20c60 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs @@ -0,0 +1,164 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class Program +{ + internal const string DefaultManifestPath = "caerius.contracts.json"; + + internal static readonly JsonSerializerOptions ManifestJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public static async Task Main(string[] args) + { + if (args.Length == 0 || args[0] is "-h" or "--help" or "help") + { + WriteUsage(); + return 0; + } + + try + { + var command = args[0]; + var options = CommandLineOptions.Parse(args.Skip(1).ToArray()); + + return command switch + { + "pull" => await PullAsync(options).ConfigureAwait(false), + "verify" => await VerifyAsync(options).ConfigureAwait(false), + _ => UnknownCommand(command) + }; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } + + private static async Task PullAsync(CommandLineOptions options) + { + var output = options.Output ?? options.Manifest ?? DefaultManifestPath; + var diagnostics = new ContractDiagnosticSink(); + var manifest = await SqlServerContractDiscoverer.DiscoverAsync(options, diagnostics).ConfigureAwait(false); + ContractDiagnosticWriter.Write(diagnostics); + + var json = JsonSerializer.Serialize(manifest, ManifestJsonOptions); + var directory = Path.GetDirectoryName(Path.GetFullPath(output)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllTextAsync(output, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + Console.WriteLine($"Wrote {output}"); + + return diagnostics.HasErrors ? 1 : 0; + } + + private static async Task VerifyAsync(CommandLineOptions options) + { + var manifestPath = options.Manifest ?? options.Output ?? DefaultManifestPath; + if (!File.Exists(manifestPath)) + { + Console.Error.WriteLine($"Manifest not found: {manifestPath}"); + return 1; + } + + var diagnostics = new ContractDiagnosticSink(); + var discovered = await SqlServerContractDiscoverer.DiscoverAsync(options, diagnostics).ConfigureAwait(false); + ContractDiagnosticWriter.Write(diagnostics); + + var discoveredJson = JsonSerializer.Serialize(discovered, ManifestJsonOptions); + var existingJson = await File.ReadAllTextAsync(manifestPath).ConfigureAwait(false); + var existingManifest = JsonSerializer.Deserialize(existingJson, ManifestJsonOptions) + ?? throw new InvalidOperationException($"Manifest could not be parsed: {manifestPath}"); + var normalizedExistingJson = JsonSerializer.Serialize(existingManifest, ManifestJsonOptions); + + if (string.Equals(discoveredJson, normalizedExistingJson, StringComparison.Ordinal)) + { + Console.WriteLine("Contract manifest is up to date."); + return diagnostics.HasErrors ? 1 : 0; + } + + ReportVerifyDiagnostics(existingManifest, discovered); + return diagnostics.HasErrors ? 1 : 2; + } + + private static void ReportVerifyDiagnostics(ContractManifest existing, ContractManifest discovered) + { + var existingProcedures = existing.Procedures.ToDictionary( + procedure => BuildFullName(procedure.Schema, procedure.Name), + StringComparer.OrdinalIgnoreCase); + var discoveredProcedures = discovered.Procedures.ToDictionary( + procedure => BuildFullName(procedure.Schema, procedure.Name), + StringComparer.OrdinalIgnoreCase); + + var emittedHashMismatch = false; + foreach (var (procedureName, discoveredProcedure) in discoveredProcedures) + { + if (!existingProcedures.TryGetValue(procedureName, out var existingProcedure)) + { + Console.Error.WriteLine( + $"CAERIUS201 error: Procedure '{procedureName}' is missing from the CaeriusNet contract manifest."); + continue; + } + + if (!string.Equals( + existingProcedure.ContractHash, + discoveredProcedure.ContractHash, + StringComparison.Ordinal)) + { + emittedHashMismatch = true; + Console.Error.WriteLine($"CAERIUS209 error: Contract hash mismatch for '{procedureName}'."); + } + } + + foreach (var procedureName in existingProcedures.Keys) + { + if (!discoveredProcedures.ContainsKey(procedureName)) + { + emittedHashMismatch = true; + Console.Error.WriteLine($"CAERIUS209 error: Contract hash mismatch for '{procedureName}'."); + } + } + + if (!emittedHashMismatch) + Console.Error.WriteLine("CAERIUS209 error: Contract hash mismatch for 'caerius.contracts.json'."); + } + + private static string BuildFullName(string schema, string name) + { + return schema + "." + name; + } + + private static int UnknownCommand(string command) + { + Console.Error.WriteLine($"Unknown command '{command}'."); + WriteUsage(); + return 1; + } + + private static void WriteUsage() + { + Console.WriteLine($""" + CaeriusNet SQL Server contracts + + Commands: + pull --connection-env CAERIUS_SQLSERVER --schemas dbo --output {DefaultManifestPath} + verify --connection-env CAERIUS_SQLSERVER --schemas dbo --manifest {DefaultManifestPath} + + Options: + --connection-env Environment variable containing the SQL Server connection string. + Defaults to CAERIUS_SQLSERVER. + --connection-string SQL Server connection string. Overrides --connection-env. + --schemas Comma or semicolon separated schemas. Defaults to dbo. + --output Output manifest path for pull. Defaults to {DefaultManifestPath}. + --manifest Manifest path for verify. + """); + } +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/README.md b/Tools/CaeriusNet.SqlServer.Contracts/README.md new file mode 100644 index 0000000..5e413d6 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/README.md @@ -0,0 +1,7 @@ +# CaeriusNet.SqlServer.Contracts + +Build-time SQL Server contract discovery package for CaeriusNet AutoContracts. + +The package contributes MSBuild `buildTransitive` imports and an embedded CLI used to pull or verify +`caerius.contracts.json` from SQL Server metadata. The source generator itself reads only `AdditionalFiles` and does not +connect to SQL Server. diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs new file mode 100644 index 0000000..86d0f3a --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs @@ -0,0 +1,423 @@ +using System.Collections.Immutable; +using System.Data; +using System.Globalization; +using Microsoft.Data.SqlClient; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class SqlServerContractDiscoverer +{ + internal static async Task DiscoverAsync( + CommandLineOptions options, + ContractDiagnosticSink diagnostics) + { + var connectionString = ResolveConnectionString(options); + var schemas = options.Schemas.Length == 0 + ? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "dbo") + : options.Schemas.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + + var database = await DiscoverDatabaseAsync(connection).ConfigureAwait(false); + var tableTypes = await DiscoverTableTypesAsync(connection, schemas, diagnostics).ConfigureAwait(false); + var procedures = await DiscoverProceduresAsync(connection, schemas, tableTypes, diagnostics) + .ConfigureAwait(false); + + return new ContractManifest( + 1, + "CaeriusNet.Generated", + database, + tableTypes, + procedures); + } + + private static string ResolveConnectionString(CommandLineOptions options) + { + string connectionString; + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + { + connectionString = options.ConnectionString; + } + else + { + var envName = options.ConnectionEnv ?? "CAERIUS_SQLSERVER"; + var value = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException( + $"Environment variable '{envName}' does not contain a SQL Server connection string."); + + connectionString = value; + } + + var builder = new SqlConnectionStringBuilder(connectionString) + { + ApplicationIntent = ApplicationIntent.ReadOnly + }; + + return builder.ConnectionString; + } + + private static async Task DiscoverDatabaseAsync(SqlConnection connection) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + DB_NAME() AS database_name, + CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS server_version, + compatibility_level + FROM sys.databases + WHERE database_id = DB_ID(); + """; + + await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow).ConfigureAwait(false); + if (!await reader.ReadAsync().ConfigureAwait(false)) + throw new InvalidOperationException("Could not read SQL Server database metadata."); + + return new DatabaseInfo( + reader.GetString(0), + reader.GetString(1), + reader.GetByte(2)); + } + + private static async Task> DiscoverTableTypesAsync( + SqlConnection connection, + ImmutableHashSet schemas, + ContractDiagnosticSink diagnostics) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + SCHEMA_NAME(tt.schema_id) AS schema_name, + tt.name AS table_type_name, + tt.user_type_id, + c.column_id, + c.name AS column_name, + COALESCE(sysTyp.name, userTyp.name) AS column_type_name, + c.max_length, + c.precision, + c.scale, + c.is_nullable + FROM sys.table_types AS tt + JOIN sys.columns AS c + ON c.object_id = tt.type_table_object_id + JOIN sys.types AS userTyp + ON userTyp.user_type_id = c.user_type_id + LEFT JOIN sys.types AS sysTyp + ON sysTyp.system_type_id = c.system_type_id + AND sysTyp.user_type_id = sysTyp.system_type_id + ORDER BY + SCHEMA_NAME(tt.schema_id), + tt.name, + c.column_id; + """; + + var tableTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + var schema = reader.GetString(0); + if (!schemas.Contains(schema)) + continue; + + var name = reader.GetString(1); + var key = schema + "." + name; + if (!tableTypes.TryGetValue(key, out var tableType)) + { + tableType = new MutableTableType(schema, name, reader.GetInt32(2)); + tableTypes.Add(key, tableType); + } + + var maxLength = reader.GetInt16(6); + var precision = reader.GetByte(7); + var scale = reader.GetByte(8); + var sqlType = SqlServerTypeName.Format(reader.GetString(5), maxLength, precision, scale); + var columnName = reader.GetString(4); + var nullable = reader.GetBoolean(9); + if (!SqlServerTypeMapper.IsSupported(sqlType)) + diagnostics.Error( + "CAERIUS203", + $"TVP column '{schema}.{name}.{columnName}' uses unsupported SQL type '{sqlType}'."); + + tableType.Columns.Add(new ColumnContract( + reader.GetInt32(3), + columnName, + sqlType, + SqlServerTypeMapper.GetClrType(sqlType, nullable), + nullable, + maxLength, + precision, + scale)); + } + + return tableTypes.Values + .OrderBy(value => value.Schema, StringComparer.OrdinalIgnoreCase) + .ThenBy(value => value.Name, StringComparer.OrdinalIgnoreCase) + .Select(value => + { + var columns = value.Columns.OrderBy(column => column.Ordinal).ToArray(); + var contractHash = ContractHasher.HashTableType(value.Schema, value.Name, columns); + return new TableTypeContract( + value.Schema, + value.Name, + CSharpName.FromSqlName(value.Name) + "Tvp", + columns, + contractHash); + }) + .ToArray(); + } + + private static async Task> DiscoverProceduresAsync( + SqlConnection connection, + ImmutableHashSet schemas, + IReadOnlyList tableTypes, + ContractDiagnosticSink diagnostics) + { + var procedures = await DiscoverProcedureHeadersAsync(connection, schemas).ConfigureAwait(false); + var tableTypesBySqlName = tableTypes.ToDictionary( + tableType => tableType.Schema + "." + tableType.Name, + StringComparer.OrdinalIgnoreCase); + + var contracts = new List(procedures.Count); + foreach (var procedure in procedures) + { + var parameters = await DiscoverParametersAsync( + connection, + procedure, + tableTypesBySqlName, + diagnostics).ConfigureAwait(false); + var resultSet = await DiscoverResultSetAsync(connection, procedure, diagnostics).ConfigureAwait(false); + + var clrName = CSharpName.FromSqlName(procedure.Name) + "Procedure"; + var contractHash = ContractHasher.HashProcedure(procedure.Schema, procedure.Name, parameters, resultSet); + + contracts.Add(new ProcedureContract( + procedure.Schema, + procedure.Name, + clrName, + CSharpName.FromSqlName(procedure.Name) + "Parameters", + resultSet.Status == "Available" ? CSharpName.FromSqlName(procedure.Name) + "Result" : null, + parameters, + resultSet, + contractHash)); + } + + return contracts + .OrderBy(contract => contract.Schema, StringComparer.OrdinalIgnoreCase) + .ThenBy(contract => contract.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static async Task> DiscoverProcedureHeadersAsync( + SqlConnection connection, + ImmutableHashSet schemas) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + p.object_id, + s.name AS schema_name, + p.name AS procedure_name + FROM sys.procedures AS p + JOIN sys.schemas AS s + ON s.schema_id = p.schema_id + WHERE p.is_ms_shipped = 0 + ORDER BY + s.name, + p.name; + """; + + var procedures = new List(); + await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + var schema = reader.GetString(1); + if (schemas.Contains(schema)) + procedures.Add(new ProcedureHeader(reader.GetInt32(0), schema, reader.GetString(2))); + } + + return procedures; + } + + private static async Task> DiscoverParametersAsync( + SqlConnection connection, + ProcedureHeader procedure, + Dictionary tableTypesBySqlName, + ContractDiagnosticSink diagnostics) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + prm.parameter_id, + prm.name AS parameter_name, + prm.is_output, + prm.is_readonly, + prm.max_length, + prm.precision, + prm.scale, + COALESCE(sysTyp.name, userTyp.name) AS type_name, + userTyp.is_table_type, + SCHEMA_NAME(userTyp.schema_id) AS user_type_schema, + TYPE_NAME(prm.user_type_id) AS user_type_name + FROM sys.parameters AS prm + JOIN sys.types AS userTyp + ON userTyp.user_type_id = prm.user_type_id + LEFT JOIN sys.types AS sysTyp + ON sysTyp.system_type_id = prm.system_type_id + AND sysTyp.user_type_id = sysTyp.system_type_id + WHERE prm.object_id = @ProcedureObjectId + ORDER BY prm.parameter_id; + """; + command.Parameters.Add(new SqlParameter("@ProcedureObjectId", SqlDbType.Int) { Value = procedure.ObjectId }); + + var parameters = new List(); + await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + var maxLength = reader.GetInt16(4); + var precision = reader.GetByte(5); + var scale = reader.GetByte(6); + var isTableType = reader.GetBoolean(8); + var isOutput = reader.GetBoolean(2); + var typeName = reader.GetString(7); + var sqlType = SqlServerTypeName.Format(typeName, maxLength, precision, scale); + var clrType = SqlServerTypeMapper.GetClrType(sqlType, false); + var parameterName = reader.GetString(1).TrimStart('@'); + var procedureName = procedure.Schema + "." + procedure.Name; + + if (isOutput) + diagnostics.Error( + "CAERIUS206", + $"Procedure '{procedureName}' has output parameter '{parameterName}', which is not supported by generated contracts."); + + if (isTableType) + { + var schema = reader.IsDBNull(9) ? string.Empty : reader.GetString(9); + var name = reader.IsDBNull(10) ? typeName : reader.GetString(10); + sqlType = schema + "." + name; + clrType = tableTypesBySqlName.TryGetValue(sqlType, out var tableType) + ? $"ReadOnlyMemory<{tableType.ClrName}>" + : "ReadOnlyMemory"; + + if (!tableTypesBySqlName.ContainsKey(sqlType)) + diagnostics.Error("CAERIUS202", + $"TVP '{sqlType}' is referenced but is missing from the contract manifest."); + } + else if (!SqlServerTypeMapper.IsSupported(sqlType)) + { + diagnostics.Error("CAERIUS207", $"SQL type '{sqlType}' cannot be mapped to generated C# code."); + } + + parameters.Add(new ParameterContract( + reader.GetInt32(0), + parameterName, + sqlType, + clrType, + isTableType, + isOutput, + false, + maxLength, + precision, + scale)); + } + + return parameters; + } + + private static async Task DiscoverResultSetAsync( + SqlConnection connection, + ProcedureHeader procedure, + ContractDiagnosticSink diagnostics) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + column_ordinal, + name AS column_name, + is_nullable, + system_type_name, + max_length, + precision, + scale, + error_number, + error_message + FROM sys.dm_exec_describe_first_result_set_for_object( + @ProcedureObjectId, + 0 + ) + WHERE is_hidden = 0 + ORDER BY column_ordinal; + """; + command.Parameters.Add(new SqlParameter("@ProcedureObjectId", SqlDbType.Int) { Value = procedure.ObjectId }); + + var columns = new List(); + string? errorMessage = null; + var procedureName = procedure.Schema + "." + procedure.Name; + + await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (!reader.IsDBNull(7)) + { + errorMessage = reader.IsDBNull(8) + ? "SQL Server could not describe the first result set." + : reader.GetString(8); + continue; + } + + if (reader.IsDBNull(0)) + continue; + + var ordinal = reader.GetInt32(0); + if (reader.IsDBNull(1)) + diagnostics.Warning( + "CAERIUS210", + $"Procedure '{procedureName}' result column {ordinal} has no name; generated contracts will use 'Column{ordinal}'."); + + var columnName = reader.IsDBNull(1) + ? "Column" + ordinal.ToString(CultureInfo.InvariantCulture) + : reader.GetString(1); + var maxLength = reader.GetInt16(4); + var precision = reader.GetByte(5); + var scale = reader.GetByte(6); + var sqlType = SqlServerTypeName.Format(reader.GetString(3), maxLength, precision, scale); + var nullable = reader.GetBoolean(2); + + if (!SqlServerTypeMapper.IsSupported(sqlType)) + diagnostics.Error("CAERIUS207", $"SQL type '{sqlType}' cannot be mapped to generated C# code."); + + if (nullable) + diagnostics.Warning( + "CAERIUS208", + $"Result column '{procedureName}.{columnName}' is nullable and will be emitted as a nullable CLR type."); + + columns.Add(new ColumnContract( + ordinal, + columnName, + sqlType, + SqlServerTypeMapper.GetClrType(sqlType, nullable), + nullable, + maxLength, + precision, + scale)); + } + + if (errorMessage is not null) + { + diagnostics.Error( + "CAERIUS204", + $"The first result set of procedure '{procedureName}' cannot be determined by SQL Server: {errorMessage}"); + return new ResultSetContract("Undetermined", [], errorMessage); + } + + if (columns.Count == 0) + { + diagnostics.Warning( + "CAERIUS205", + $"Procedure '{procedureName}' has no result set; no result DTO will be generated."); + return new ResultSetContract("None", [], null); + } + + return new ResultSetContract("Available", columns.OrderBy(column => column.Ordinal).ToArray(), null); + } +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs new file mode 100644 index 0000000..9caf560 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs @@ -0,0 +1,59 @@ +namespace CaeriusNet.SqlServer.Contracts; + +internal static class SqlServerTypeMapper +{ + internal static string GetClrType(string sqlType, bool nullable) + { + var clrType = Normalize(sqlType) switch + { + "bit" => "bool", + "tinyint" => "byte", + "smallint" => "short", + "int" => "int", + "bigint" => "long", + "real" => "float", + "float" => "double", + "decimal" or "numeric" or "money" or "smallmoney" => "decimal", + "date" => "DateOnly", + "time" => "TimeOnly", + "datetime" or "datetime2" or "smalldatetime" => "DateTime", + "datetimeoffset" => "DateTimeOffset", + "uniqueidentifier" => "Guid", + "nvarchar" or "varchar" or "nchar" or "char" => "string", + "binary" or "varbinary" => "byte[]", + _ => "object" + }; + + if (!nullable) + return clrType; + + return clrType + "?"; + } + + internal static bool IsSupported(string sqlType) + { + return Normalize(sqlType) switch + { + "bit" or "tinyint" or "smallint" or "int" or "bigint" or + "real" or "float" or "decimal" or "numeric" or "money" or "smallmoney" or + "date" or "time" or "datetime" or "datetime2" or "smalldatetime" or "datetimeoffset" or + "uniqueidentifier" or "nvarchar" or "varchar" or "nchar" or "char" or + "varbinary" or "binary" => true, + _ => false + }; + } + + private static string Normalize(string sqlType) + { + var span = sqlType.AsSpan().Trim(); + var paren = span.IndexOf('('); + if (paren >= 0) + span = span[..paren]; + + var dot = span.LastIndexOf('.'); + if (dot >= 0) + span = span[(dot + 1)..]; + + return span.ToString().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs new file mode 100644 index 0000000..2fbe118 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class SqlServerTypeName +{ + internal static string Format(string sqlType, int maxLength, byte precision, byte scale) + { + var normalized = Normalize(sqlType); + return normalized switch + { + "nvarchar" or "nchar" => normalized + "(" + FormatLength(maxLength, true) + ")", + "varchar" or "char" or "varbinary" or "binary" => + normalized + "(" + FormatLength(maxLength, false) + ")", + "decimal" or "numeric" => normalized + "(" + precision + "," + scale + ")", + "time" or "datetime2" or "datetimeoffset" => normalized + "(" + scale + ")", + _ => normalized + }; + } + + private static string FormatLength(int maxLength, bool divideByTwo) + { + if (maxLength == -1) + return "max"; + + var length = divideByTwo ? maxLength / 2 : maxLength; + return Math.Max(length, 1).ToString(CultureInfo.InvariantCulture); + } + + private static string Normalize(string sqlType) + { + var span = sqlType.AsSpan().Trim(); + var paren = span.IndexOf('('); + if (paren >= 0) + span = span[..paren]; + + var dot = span.LastIndexOf('.'); + if (dot >= 0) + span = span[(dot + 1)..]; + + return span.ToString().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props new file mode 100644 index 0000000..639a584 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props @@ -0,0 +1,20 @@ + + + Off + $([System.String]::Copy('$(CaeriusContractsMode)').Trim().ToUpperInvariant()) + dbo + $(ProjectDir)caerius.contracts.json + CAERIUS_SQLSERVER + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\tools\net10.0\any\CaeriusNet.SqlServer.Contracts.dll')) + dotnet "$(CaeriusContractsToolPath)" + + + + + + + + + + + diff --git a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets new file mode 100644 index 0000000..f25e0a2 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 6db19559dfd91a635e9b3149c6023451c0cfc649 Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 18:44:46 +0200 Subject: [PATCH 3/8] chore(ci): validate autocontracts packaging --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- {eng => .github/scripts}/ValidatePackage.ps1 | 94 +++++- .github/workflows/benchmark.yml | 1 + .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 14 +- .gitignore | 6 +- Benchmark/Program.cs | 7 +- Benchmark/README.md | 6 +- Benchmark/RunningBenchmarks.cs | 19 +- .../AutoContractsPlanningBenchmarks.cs | 278 ++++++++++++++++++ Documentations/docs/benchmarks/index.md | 5 +- 11 files changed, 418 insertions(+), 16 deletions(-) rename {eng => .github/scripts}/ValidatePackage.ps1 (61%) create mode 100644 Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d02779e..577d5e4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,7 +24,7 @@ - [ ] I have added or updated tests covering my change. - [ ] `dotnet build CaeriusNet.slnx -c Release -p:TreatWarningsAsErrors=true` succeeds locally. - [ ] `dotnet test CaeriusNet.slnx -c Release --filter "FullyQualifiedName!~IntegrationTests"` is green. -- [ ] `pwsh ./eng/ValidatePackage.ps1 -Configuration Release` succeeds for package/public API changes. +- [ ] `pwsh ./.github/scripts/ValidatePackage.ps1 -Configuration Release -OutputDirectory .work/package-validation` succeeds for package/public API changes. - [ ] `cd Documentations && npm install && npm run docs:build` succeeds for documentation changes (or `npm ci` when a lockfile exists). - [ ] Docker-backed integration tests were run when storage, transactions, SQL, or TVP behaviour changed. - [ ] I have updated [`CHANGELOG.md`](../CHANGELOG.md) under `[Unreleased]` if user-visible. diff --git a/eng/ValidatePackage.ps1 b/.github/scripts/ValidatePackage.ps1 similarity index 61% rename from eng/ValidatePackage.ps1 rename to .github/scripts/ValidatePackage.ps1 index 5aec1fb..fa69bb1 100644 --- a/eng/ValidatePackage.ps1 +++ b/.github/scripts/ValidatePackage.ps1 @@ -1,12 +1,12 @@ [CmdletBinding()] param( [string]$Configuration = "Release", - [string]$OutputDirectory = "artifacts\package-validation" + [string]$OutputDirectory = ".work\package-validation" ) $ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent $PSScriptRoot +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } @@ -74,6 +74,17 @@ try { throw "Could not infer package version from '$($package.Name)'." } + $globalPackagesLine = dotnet nuget locals global-packages --list + if ($LASTEXITCODE -ne 0) { + throw "Could not locate NuGet global packages cache." + } + + $globalPackagesRoot = (($globalPackagesLine | Select-Object -First 1) -replace '^global-packages:\s*', '').Trim() + $cachedPackageDirectory = Join-Path $globalPackagesRoot (Join-Path "caeriusnet" $version) + if (Test-Path $cachedPackageDirectory) { + Remove-Item $cachedPackageDirectory -Recurse -Force + } + New-Item -ItemType Directory -Path $consumerDirectory -Force | Out-Null $packageSource = $packageDirectory @@ -100,14 +111,70 @@ try { + "@ | Set-Content -Path (Join-Path $consumerDirectory "PackageSmoke.Consumer.csproj") -Encoding UTF8 + @" +{ + "version": 1, + "namespace": "PackageSmoke.Contracts", + "tableTypes": [ + { + "schema": "dbo", + "name": "CustomerIdRows", + "clrName": "CustomerIdRowsTvp", + "columns": [ + { "ordinal": 1, "name": "Id", "sqlType": "int", "clrType": "int", "nullable": false } + ], + "contractHash": "sha256:table" + } + ], + "procedures": [ + { + "schema": "dbo", + "name": "Customer_Search", + "clrName": "CustomerSearchProcedure", + "parametersClrName": "CustomerSearchParameters", + "resultClrName": "CustomerSearchResult", + "parameters": [ + { + "ordinal": 1, + "name": "Ids", + "sqlType": "dbo.CustomerIdRows", + "clrType": "ReadOnlyMemory", + "isTableType": true, + "nullable": false + }, + { + "ordinal": 2, + "name": "IncludeDisabled", + "sqlType": "bit", + "clrType": "bool", + "isTableType": false, + "nullable": false + } + ], + "resultSet": { + "status": "Available", + "columns": [ + { "ordinal": 1, "name": "Id", "sqlType": "int", "clrType": "int", "nullable": false }, + { "ordinal": 2, "name": "Name", "sqlType": "nvarchar(64)", "clrType": "string", "nullable": false, "maxLength": 128 } + ] + }, + "contractHash": "sha256:procedure" + } + ] +} +"@ | Set-Content -Path (Join-Path $consumerDirectory "caerius.contracts.json") -Encoding UTF8 + @" using CaeriusNet.Attributes.Dto; using CaeriusNet.Attributes.Tvp; +using CaeriusNet.Builders; using CaeriusNet.Mappers; +using PackageSmoke.Contracts; namespace PackageSmoke; @@ -132,7 +199,28 @@ internal static class Program var mapper = new CustomerRow(1, "Ada"); using var records = mapper.MapAsSqlDataRecords([mapper]).GetEnumerator(); - return records.MoveNext() ? 0 : 2; + if (!records.MoveNext()) + { + return 2; + } + + ReadOnlyMemory ids = new CustomerIdRowsTvp[] { new(1), new(2) }; + var autoContractParameters = new CustomerSearchParameters(ids, IncludeDisabled: false); + var typed = StoredProcedureParametersBuilder + .Create(autoContractParameters, resultSetCapacity: ids.Length) + .Build(); + + if (typed.SchemaName != "dbo" || typed.ProcedureName != "Customer_Search") + { + return 3; + } + + if (typed.GetParametersSpan().Length != 2) + { + return 4; + } + + return 0; } private static void EnsureDto() where T : class, ISpMapper diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4062ca8..d25fac0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -20,6 +20,7 @@ on: - sql-server - collections - cache + - auto-contracts # Minimal default permissions permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf5f362..dd12fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: - name: Validate NuGet package shell: pwsh - run: ./eng/ValidatePackage.ps1 -Configuration Release + run: ./.github/scripts/ValidatePackage.ps1 -Configuration Release -OutputDirectory "${{ runner.temp }}/caerius-package-validation" - name: Run tests with coverage run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2733c33..c72b540 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: key: nuget-release-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.slnx') }} restore-keys: nuget-release-${{ runner.os }}- - # ── Bump version in .csproj ───────────────────────────────────────────────── + # ── Bump package versions ─────────────────────────────────────────────────── - name: Bump version id: bump run: | @@ -92,6 +92,7 @@ jobs: esac sed -i "s|${CURRENT}|${NEW}|g" Src/CaeriusNet.csproj + sed -i "s|${CURRENT}|${NEW}|g" Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj echo "current=$CURRENT" >> "$GITHUB_OUTPUT" echo "new=$NEW" >> "$GITHUB_OUTPUT" @@ -166,9 +167,16 @@ jobs: -p:IncludeSymbols=true \ -p:SymbolPackageFormat=snupkg + dotnet pack Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj \ + --configuration Release \ + --no-build \ + --output ./packages \ + -p:IncludeSymbols=true \ + -p:SymbolPackageFormat=snupkg + - name: Validate package contents shell: pwsh - run: ./eng/ValidatePackage.ps1 -Configuration Release -OutputDirectory artifacts/package-validation + run: ./.github/scripts/ValidatePackage.ps1 -Configuration Release -OutputDirectory "${{ runner.temp }}/caerius-package-validation" - name: List packages run: ls -la ./packages/ @@ -202,7 +210,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add Src/CaeriusNet.csproj + git add Src/CaeriusNet.csproj Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj git commit -m "chore: release v${{ steps.bump.outputs.new }} [skip ci]" git push echo "✅ Version bump committed: ${{ steps.bump.outputs.new }}" diff --git a/.gitignore b/.gitignore index 393348c..8fd0afd 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ +coverage/ +**/%TEMP%/ # ASP.NET Scaffolding ScaffoldingReadMe.txt @@ -97,6 +99,7 @@ StyleCopReport.xml *.pidb *.svclog *.scc +/.work # Chutzpah Test files _Chutzpah* @@ -401,4 +404,5 @@ FodyWeavers.xsd .idea/* /.idea/* -/Documentations/docs/.vitepress/cache \ No newline at end of file +/Documentations/docs/.vitepress/cache +/Documentations/docs/.vitepress/dist diff --git a/Benchmark/Program.cs b/Benchmark/Program.cs index 7ce0ac2..9bfc873 100644 --- a/Benchmark/Program.cs +++ b/Benchmark/Program.cs @@ -1,5 +1,4 @@ using CaeriusNet.Benchmark; - // In CI: runs all benchmarks (category controlled by CI workflow args) // Locally: same — use RunningBenchmarks.Run_InMemory_Benchmarks() etc. directly var category = args.Length > 0 ? args[0].ToLowerInvariant() : "all"; @@ -22,7 +21,11 @@ case "cache": RunningBenchmarks.Run_Cache_Benchmarks(); break; + case "auto-contracts": + case "autocontracts": + RunningBenchmarks.Run_AutoContracts_Benchmarks(); + break; default: RunningBenchmarks.Run_All_Benchmarks(); break; -} \ No newline at end of file +} diff --git a/Benchmark/README.md b/Benchmark/README.md index 71db903..b3a0ed3 100644 --- a/Benchmark/README.md +++ b/Benchmark/README.md @@ -2,8 +2,8 @@ ## Overview -BenchmarkDotNet benchmarks measure mapping throughput, collection behavior, cache performance, TVP serialization, and -end-to-end SQL Server execution. +BenchmarkDotNet benchmarks measure mapping throughput, AutoContracts generated hot paths, collection behavior, cache +performance, TVP serialization, and end-to-end SQL Server execution. ## Prerequisites @@ -26,6 +26,7 @@ dotnet run -c Release -- in-memory dotnet run -c Release -- tvp dotnet run -c Release -- collections dotnet run -c Release -- cache +dotnet run -c Release -- auto-contracts dotnet run -c Release -- sql-server ``` @@ -42,6 +43,7 @@ If `BENCHMARK_SQL_CONNECTION` is not set, SQL Server benchmarks are skipped. | Category | Scope | I/O | |-------------------|---------------------------------------------------------------------------------------|------------| +| AutoContracts | Generated contract builder, ordinal mapping, and cache key hashing | None | | Cache | `FrozenDictionary` and `IMemoryCache` throughput | None | | CreateCollections | Collection creation performance | None | | ListCapacity | Pre-allocation versus dynamic growth | None | diff --git a/Benchmark/RunningBenchmarks.cs b/Benchmark/RunningBenchmarks.cs index 7ccbfa3..b8e86ff 100644 --- a/Benchmark/RunningBenchmarks.cs +++ b/Benchmark/RunningBenchmarks.cs @@ -1,4 +1,5 @@ using CaeriusNet.Benchmark.Workshops; +using CaeriusNet.Benchmark.Workshops.Benchs.AutoContracts; using CaeriusNet.Benchmark.Workshops.Benchs.Cache; using CaeriusNet.Benchmark.Workshops.Benchs.CreateCollections; using CaeriusNet.Benchmark.Workshops.Benchs.ListCapacity; @@ -21,7 +22,7 @@ private static IConfig GetConfig() return new BenchmarkConfig(); } - /// Runs ALL benchmarks (in-memory + TVP + collection + cache + SQL Server). + /// Runs ALL benchmarks (in-memory + AutoContracts + TVP + collection + cache + SQL Server). public static void Run_All_Benchmarks() { BenchmarkRunner.Run([ @@ -29,6 +30,9 @@ public static void Run_All_Benchmarks() typeof(DtoMappingBench), typeof(WideRowDtoMappingBench), typeof(NullableColumnMappingBench), + // AutoContracts: generated stored-procedure contract hot paths + typeof(AutoContractsBuilderBenchmarks), + typeof(AutoContractsGeneratedHotPathBenchmarks), // In-memory: StoredProcedureParametersBuilder overhead typeof(SpParameterBuilderBench), typeof(AddTvpParameterBench), @@ -71,6 +75,8 @@ public static void Run_InMemory_Benchmarks() typeof(DtoMappingBench), typeof(WideRowDtoMappingBench), typeof(NullableColumnMappingBench), + typeof(AutoContractsBuilderBenchmarks), + typeof(AutoContractsGeneratedHotPathBenchmarks), typeof(SpParameterBuilderBench), typeof(AddTvpParameterBench), typeof(TvpSerializationBench), @@ -139,4 +145,13 @@ public static void Run_Cache_Benchmarks() typeof(InMemoryCacheBench) ], GetConfig()); } -} \ No newline at end of file + + /// Runs generated AutoContracts benchmarks only. + public static void Run_AutoContracts_Benchmarks() + { + BenchmarkRunner.Run([ + typeof(AutoContractsBuilderBenchmarks), + typeof(AutoContractsGeneratedHotPathBenchmarks) + ], GetConfig()); + } +} diff --git a/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs b/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs new file mode 100644 index 0000000..ce7f2c3 --- /dev/null +++ b/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs @@ -0,0 +1,278 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace CaeriusNet.Benchmark.Workshops.Benchs.AutoContracts; + +/// +/// Measures the generated AutoContracts parameter-builder facade. +/// +[Config(typeof(BenchmarkConfig))] +[MemoryDiagnoser] +public sealed class AutoContractsBuilderBenchmarks +{ + private static readonly BenchmarkProcedureParameters Parameters = + new(42, Guid.Parse("7e85a27d-73ba-4a20-98a9-2b3f57200d1d"), "benchmark", 12.50m, true); + + [Benchmark(Baseline = true, Description = "Untyped StoredProcedureParametersBuilder")] + public StoredProcedureParameters UntypedParameterBuilder() + { + return new StoredProcedureParametersBuilder("dbo", "usp_AutoContracts_Benchmark") + .AddParameter("@Id", Parameters.Id, SqlDbType.Int) + .AddParameter("@TraceId", Parameters.TraceId, SqlDbType.UniqueIdentifier) + .AddParameter("@Name", Parameters.Name, SqlDbType.NVarChar) + .AddParameter("@Amount", Parameters.Amount, SqlDbType.Decimal) + .AddParameter("@Active", Parameters.Active, SqlDbType.Bit) + .Build(); + } + + [Benchmark(Description = "Typed generated parameter builder")] + public StoredProcedureParameters TypedGeneratedParameterBuilder() + { + return StoredProcedureParametersBuilder + .Create(16) + .WithParameters(Parameters) + .Build(); + } + + [Benchmark(Description = "Typed generated parameter builder with cache key")] + public StoredProcedureParameters TypedGeneratedParameterBuilderWithCacheKey() + { + return StoredProcedureParametersBuilder + .Create(16) + .WithParameters(Parameters) + .AddFrozenCache(BenchmarkProcedureCacheKeys.ByParameters(Parameters)) + .Build(); + } +} + +/// +/// Measures generated AutoContracts hot paths that run after SQL execution planning. +/// +[Config(typeof(BenchmarkConfig))] +[MemoryDiagnoser] +public sealed class AutoContractsGeneratedHotPathBenchmarks +{ + private int[] _ids = null!; + private bool[] _isActives = null!; + private string[] _names = null!; + private BenchmarkProcedureParameters[] _parameters = null!; + private decimal[] _prices = null!; + private Guid[] _traceIds = null!; + + [Params(1, 100, 1_000, 10_000)] public int RowCount { get; set; } + + [GlobalSetup] + public void Setup() + { + var random = new Random(42); + _ids = new int[RowCount]; + _traceIds = new Guid[RowCount]; + _names = new string[RowCount]; + _prices = new decimal[RowCount]; + _isActives = new bool[RowCount]; + _parameters = new BenchmarkProcedureParameters[RowCount]; + + for (var i = 0; i < RowCount; i++) + { + _ids[i] = random.Next(1, 100_000); + _traceIds[i] = Guid.NewGuid(); + _names[i] = "user_" + i; + _prices[i] = Math.Round((decimal)(random.NextDouble() * 9999), 2); + _isActives[i] = i % 2 == 0; + _parameters[i] = new BenchmarkProcedureParameters( + _ids[i], + _traceIds[i], + _names[i], + _prices[i], + _isActives[i]); + } + } + + [Benchmark(Baseline = true, Description = "Generated ordinal result mapping")] + public BenchmarkResultRow[] GeneratedOrdinalResultMapping() + { + var result = new BenchmarkResultRow[RowCount]; + for (var row = 0; row < RowCount; row++) + result[row] = BenchmarkResultRowMapper.Map(new OrdinalRowReader( + row, + _ids, + _traceIds, + _names, + _prices, + _isActives)); + + return result; + } + + [Benchmark(Description = "Generated cache key hashing")] + public string[] GeneratedCacheKeyHashing() + { + var result = new string[RowCount]; + for (var i = 0; i < RowCount; i++) + result[i] = BenchmarkProcedureCacheKeys.ByParameters(_parameters[i]); + + return result; + } +} + +public readonly struct BenchmarkProcedure : ICaeriusGeneratedProcedure +{ + public static string SchemaName => "dbo"; + public static string ProcedureName => "usp_AutoContracts_Benchmark"; + public static string FullName => "dbo.usp_AutoContracts_Benchmark"; + public static string ContractHash => "sha256:benchmark"; + public static int ParameterCount => 5; + public static int ResultSetCount => 1; +} + +public sealed record BenchmarkProcedureParameters( + int Id, + Guid TraceId, + string Name, + decimal Amount, + bool Active); + +public readonly record struct BenchmarkResultRow( + int Id, + Guid TraceId, + string Name, + decimal Amount, + bool Active); + +public static class BenchmarkProcedureBuilderExtensions +{ + public static StoredProcedureParametersBuilder WithParameters( + this StoredProcedureParametersBuilder builder, + BenchmarkProcedureParameters parameters) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(parameters); + + return builder + .AddGeneratedParameter(1, "@Id", parameters.Id, SqlDbType.Int) + .AddGeneratedParameter(2, "@TraceId", parameters.TraceId, SqlDbType.UniqueIdentifier) + .AddGeneratedParameter(3, "@Name", parameters.Name, SqlDbType.NVarChar, 64) + .AddGeneratedParameter(4, "@Amount", parameters.Amount, SqlDbType.Decimal, precision: 18, scale: 2) + .AddGeneratedParameter(5, "@Active", parameters.Active, SqlDbType.Bit) + .MarkGeneratedParametersBound(); + } +} + +public static class BenchmarkResultRowMapper +{ + public static BenchmarkResultRow Map(OrdinalRowReader reader) + { + return new BenchmarkResultRow( + reader.GetInt32(0), + reader.GetGuid(1), + reader.GetString(2), + reader.GetDecimal(3), + reader.GetBoolean(4)); + } +} + +public readonly ref struct OrdinalRowReader( + int row, + int[] ids, + Guid[] traceIds, + string[] names, + decimal[] amounts, + bool[] actives) +{ + public int GetInt32(int ordinal) + { + return ordinal == 0 ? ids[row] : throw new ArgumentOutOfRangeException(nameof(ordinal)); + } + + public Guid GetGuid(int ordinal) + { + return ordinal == 1 ? traceIds[row] : throw new ArgumentOutOfRangeException(nameof(ordinal)); + } + + public string GetString(int ordinal) + { + return ordinal == 2 ? names[row] : throw new ArgumentOutOfRangeException(nameof(ordinal)); + } + + public decimal GetDecimal(int ordinal) + { + return ordinal == 3 ? amounts[row] : throw new ArgumentOutOfRangeException(nameof(ordinal)); + } + + public bool GetBoolean(int ordinal) + { + return ordinal == 4 ? actives[row] : throw new ArgumentOutOfRangeException(nameof(ordinal)); + } +} + +public static class BenchmarkProcedureCacheKeys +{ + public static string ByParameters(BenchmarkProcedureParameters parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendString(hash, BenchmarkProcedure.FullName); + AppendString(hash, BenchmarkProcedure.ContractHash); + + AppendString(hash, "@Id"); + AppendInt32(hash, parameters.Id); + AppendString(hash, "@TraceId"); + AppendGuid(hash, parameters.TraceId); + AppendString(hash, "@Name"); + AppendString(hash, parameters.Name); + AppendString(hash, "@Amount"); + AppendDecimal(hash, parameters.Amount); + AppendString(hash, "@Active"); + AppendBoolean(hash, parameters.Active); + + return BenchmarkProcedure.FullName + ":" + Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static void AppendBoolean(IncrementalHash hash, bool value) + { + AppendByte(hash, value ? (byte)1 : (byte)0); + } + + private static void AppendByte(IncrementalHash hash, byte value) + { + Span buffer = stackalloc byte[1]; + buffer[0] = value; + hash.AppendData(buffer); + } + + private static void AppendInt32(IncrementalHash hash, int value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(buffer, value); + hash.AppendData(buffer); + } + + private static void AppendGuid(IncrementalHash hash, Guid value) + { + Span buffer = stackalloc byte[16]; + value.TryWriteBytes(buffer); + hash.AppendData(buffer); + } + + private static void AppendDecimal(IncrementalHash hash, decimal value) + { + foreach (var part in decimal.GetBits(value)) + AppendInt32(hash, part); + } + + private static void AppendString(IncrementalHash hash, string? value) + { + if (value is null) + { + AppendByte(hash, 0); + return; + } + + AppendByte(hash, 1); + var bytes = Encoding.UTF8.GetBytes(value); + AppendInt32(hash, bytes.Length); + hash.AppendData(bytes); + } +} \ No newline at end of file diff --git a/Documentations/docs/benchmarks/index.md b/Documentations/docs/benchmarks/index.md index 917db5d..b444396 100644 --- a/Documentations/docs/benchmarks/index.md +++ b/Documentations/docs/benchmarks/index.md @@ -21,7 +21,7 @@ Benchmark results are diagnostic data, not performance guarantees. Compare trend | Page | Scope | |---|---| -| [In-Memory Benchmarks](./in-memory) | DTO mapping, TVP serialization, parameter builder — pure CPU/allocation, no I/O | +| [In-Memory Benchmarks](./in-memory) | DTO mapping, AutoContracts generated hot paths, TVP serialization, parameter builder — pure CPU/allocation, no I/O | | [Collection Benchmarks](./collections) | Read / create performance of `List`, `ReadOnlyCollection`, `IEnumerable`, `ImmutableArray` | | [SQL Server Benchmarks](./sql-server) | End-to-end stored procedure execution, batched vs single inserts, TVP full roundtrip | | [Cache Benchmarks](./cache) | `FrozenDictionary` (frozen cache) and `IMemoryCache` (in-memory cache) throughput | @@ -149,6 +149,9 @@ dotnet run -c Release -- collections # Cache layer benchmarks (FrozenDictionary + IMemoryCache) dotnet run -c Release -- cache +# AutoContracts generated-contract hot paths +dotnet run -c Release -- auto-contracts + # SQL Server end-to-end (requires BENCHMARK_SQL_CONNECTION) export BENCHMARK_SQL_CONNECTION="Server=localhost,1433;Database=master;User Id=sa;Password=YourP@ssword!;TrustServerCertificate=True" dotnet run -c Release -- sql-server From b94bd87eb3956a00dfb4904802725e7ebfc2942c Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 18:46:14 +0200 Subject: [PATCH 4/8] docs(autocontracts): document contracts workflow --- Analyzer/Readme.md | 24 ++++--- CONTRIBUTING.md | 8 ++- Documentations/diagnostics/CAERIUS001.md | 2 +- Documentations/diagnostics/CAERIUS002.md | 2 +- Documentations/diagnostics/CAERIUS003.md | 2 +- Documentations/diagnostics/CAERIUS004.md | 2 +- Documentations/diagnostics/CAERIUS005.md | 2 +- Documentations/diagnostics/CAERIUS006.md | 2 +- Documentations/diagnostics/CAERIUS200.md | 23 +++++++ Documentations/diagnostics/CAERIUS201.md | 17 +++++ Documentations/diagnostics/CAERIUS202.md | 18 ++++++ Documentations/diagnostics/CAERIUS203.md | 15 +++++ Documentations/diagnostics/CAERIUS204.md | 18 ++++++ Documentations/diagnostics/CAERIUS205.md | 17 +++++ Documentations/diagnostics/CAERIUS206.md | 18 ++++++ Documentations/diagnostics/CAERIUS207.md | 16 +++++ Documentations/diagnostics/CAERIUS208.md | 17 +++++ Documentations/diagnostics/CAERIUS209.md | 18 ++++++ Documentations/diagnostics/CAERIUS210.md | 17 +++++ Documentations/docs/.vitepress/config.mts | 16 ++++- Documentations/docs/diagnostics/CAERIUS001.md | 2 +- Documentations/docs/diagnostics/CAERIUS002.md | 2 +- Documentations/docs/diagnostics/CAERIUS003.md | 2 +- Documentations/docs/diagnostics/CAERIUS004.md | 2 +- Documentations/docs/diagnostics/CAERIUS005.md | 2 +- Documentations/docs/diagnostics/CAERIUS006.md | 21 +++++++ Documentations/docs/diagnostics/CAERIUS200.md | 23 +++++++ Documentations/docs/diagnostics/CAERIUS201.md | 17 +++++ Documentations/docs/diagnostics/CAERIUS202.md | 18 ++++++ Documentations/docs/diagnostics/CAERIUS203.md | 15 +++++ Documentations/docs/diagnostics/CAERIUS204.md | 18 ++++++ Documentations/docs/diagnostics/CAERIUS205.md | 17 +++++ Documentations/docs/diagnostics/CAERIUS206.md | 18 ++++++ Documentations/docs/diagnostics/CAERIUS207.md | 16 +++++ Documentations/docs/diagnostics/CAERIUS208.md | 17 +++++ Documentations/docs/diagnostics/CAERIUS209.md | 18 ++++++ Documentations/docs/diagnostics/CAERIUS210.md | 17 +++++ Documentations/docs/diagnostics/index.md | 18 +++++- .../docs/documentation/auto-contracts.md | 63 +++++++++++++++++++ .../docs/documentation/diagnostics.md | 18 +++++- .../docs/documentation/source-generators.md | 10 ++- Documentations/docs/documentation/usage.md | 1 + README.md | 2 +- SourceGenerators/README.md | 53 +++++++++------- Tests/README.md | 32 ++++++++-- 45 files changed, 613 insertions(+), 63 deletions(-) create mode 100644 Documentations/diagnostics/CAERIUS200.md create mode 100644 Documentations/diagnostics/CAERIUS201.md create mode 100644 Documentations/diagnostics/CAERIUS202.md create mode 100644 Documentations/diagnostics/CAERIUS203.md create mode 100644 Documentations/diagnostics/CAERIUS204.md create mode 100644 Documentations/diagnostics/CAERIUS205.md create mode 100644 Documentations/diagnostics/CAERIUS206.md create mode 100644 Documentations/diagnostics/CAERIUS207.md create mode 100644 Documentations/diagnostics/CAERIUS208.md create mode 100644 Documentations/diagnostics/CAERIUS209.md create mode 100644 Documentations/diagnostics/CAERIUS210.md create mode 100644 Documentations/docs/diagnostics/CAERIUS006.md create mode 100644 Documentations/docs/diagnostics/CAERIUS200.md create mode 100644 Documentations/docs/diagnostics/CAERIUS201.md create mode 100644 Documentations/docs/diagnostics/CAERIUS202.md create mode 100644 Documentations/docs/diagnostics/CAERIUS203.md create mode 100644 Documentations/docs/diagnostics/CAERIUS204.md create mode 100644 Documentations/docs/diagnostics/CAERIUS205.md create mode 100644 Documentations/docs/diagnostics/CAERIUS206.md create mode 100644 Documentations/docs/diagnostics/CAERIUS207.md create mode 100644 Documentations/docs/diagnostics/CAERIUS208.md create mode 100644 Documentations/docs/diagnostics/CAERIUS209.md create mode 100644 Documentations/docs/diagnostics/CAERIUS210.md create mode 100644 Documentations/docs/documentation/auto-contracts.md diff --git a/Analyzer/Readme.md b/Analyzer/Readme.md index b7b3107..216b965 100644 --- a/Analyzer/Readme.md +++ b/Analyzer/Readme.md @@ -1,21 +1,25 @@ # CaeriusNet Analyzer -Roslyn analyzer project that owns the user-facing `CAERIUS` diagnostics for `[GenerateDto]` and `[GenerateTvp]`. +Roslyn analyzer project that owns the user-facing `CAERIUS` diagnostics for `[GenerateDto]`, `[GenerateTvp]`, and +SQL Server AutoContracts manifests. ## Purpose -The analyzer validates generator-target shapes and reports diagnostics during IDE/CLI compilation, while -`CaeriusNet.Generator` stays focused on incremental discovery, extraction, and source emission. +The analyzer validates generator-target shapes and `caerius.contracts.json` content during IDE/CLI compilation, while +`CaeriusNet.Generator` stays focused on incremental discovery, extraction, and source emission. This keeps Roslyn +incremental generators deterministic and keeps all user-facing diagnostics in one analyzer compartment. ## Rules -| ID | Severity | Description | -|------------|----------|----------------------------------------------------------------| -| CAERIUS001 | Error | Target type must be `sealed` | -| CAERIUS002 | Error | Target type must be `partial` | -| CAERIUS003 | Error | Target type must declare a primary constructor with parameters | -| CAERIUS004 | Error | `[GenerateTvp]` requires a non-empty `TvpName` | -| CAERIUS005 | Warning | A constructor parameter falls back to `sql_variant` | +| ID | Severity | Description | +|----------------|-----------------|----------------------------------------------------------------| +| CAERIUS001 | Error | Target type must be `sealed` | +| CAERIUS002 | Error | Target type must be `partial` | +| CAERIUS003 | Error | Target type must declare a primary constructor with parameters | +| CAERIUS004 | Error | `[GenerateTvp]` requires a non-empty `TvpName` | +| CAERIUS005 | Warning | A constructor parameter falls back to `sql_variant` | +| CAERIUS006 | Error | Generator target must be a supported top-level type | +| CAERIUS200-210 | Error / Warning | AutoContracts manifest and SQL contract diagnostics | ## Tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a07ae1..e71a313 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,9 +49,11 @@ dotnet test Tests/CaeriusNet.IntegrationTests/CaeriusNet.IntegrationTests.csproj ``` Src/ # Public runtime API (the NuGet package) -SourceGenerators/ # Roslyn source generators (DTO + TVP mappers, diagnostics) +SourceGenerators/ # Roslyn incremental generators (DTO, TVP, AutoContracts emission) +Analyzer/ # Roslyn analyzers and user-facing CAERIUS diagnostics Tests/CaeriusNet.Tests/ # Pure unit tests (no IO) -Tests/CaeriusNet.Generator.Tests/ # Source-generator emit & diagnostic tests +Tests/CaeriusNet.Generator.Tests/ # Source-generator emit and caching tests +Tests/CaeriusNet.Analyzer.Tests/ # Analyzer diagnostic tests Tests/CaeriusNet.IntegrationTests/ # End-to-end tests (Testcontainers MSSQL) Benchmark/ # BenchmarkDotNet suites Exemples/ # Example consumer apps (directory name retained for compatibility) @@ -89,7 +91,7 @@ migration path in the PR description. | `dotnet build CaeriusNet.slnx -c Release -p:TreatWarningsAsErrors=true` | Mirrors CI. | | `dotnet test CaeriusNet.slnx -c Release --filter "FullyQualifiedName!~IntegrationTests"` | Unit + generator tests (fast, no Docker). | | `dotnet test Tests/CaeriusNet.IntegrationTests` | End-to-end tests; needs Docker. | -| `pwsh ./eng/ValidatePackage.ps1 -Configuration Release` | Packs the NuGet package and smoke-tests a consumer project. | +| `pwsh ./.github/scripts/ValidatePackage.ps1 -Configuration Release -OutputDirectory .work/package-validation` | Packs the NuGet package and smoke-tests a consumer project. | | `cd Documentations && npm install && npm run docs:build` | Builds the VitePress docs when npm is available; use `npm ci` if a lockfile exists. | | `dotnet test --collect:"XPlat Code Coverage"` | Generates Cobertura coverage. | diff --git a/Documentations/diagnostics/CAERIUS001.md b/Documentations/diagnostics/CAERIUS001.md index 1229819..3856e2a 100644 --- a/Documentations/diagnostics/CAERIUS001.md +++ b/Documentations/diagnostics/CAERIUS001.md @@ -1,7 +1,7 @@ # CAERIUS001 — Type must be `sealed` **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS002.md b/Documentations/diagnostics/CAERIUS002.md index 49ff664..9b8ce5a 100644 --- a/Documentations/diagnostics/CAERIUS002.md +++ b/Documentations/diagnostics/CAERIUS002.md @@ -1,7 +1,7 @@ # CAERIUS002 — Type must be `partial` **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS003.md b/Documentations/diagnostics/CAERIUS003.md index dbba241..1b6ecd7 100644 --- a/Documentations/diagnostics/CAERIUS003.md +++ b/Documentations/diagnostics/CAERIUS003.md @@ -1,7 +1,7 @@ # CAERIUS003 — Type must declare a primary constructor **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS004.md b/Documentations/diagnostics/CAERIUS004.md index b7f6371..bd59bf5 100644 --- a/Documentations/diagnostics/CAERIUS004.md +++ b/Documentations/diagnostics/CAERIUS004.md @@ -1,7 +1,7 @@ # CAERIUS004 — `TvpName` must not be empty **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS005.md b/Documentations/diagnostics/CAERIUS005.md index a1384f3..c4a0987 100644 --- a/Documentations/diagnostics/CAERIUS005.md +++ b/Documentations/diagnostics/CAERIUS005.md @@ -1,7 +1,7 @@ # CAERIUS005 — Unmapped CLR type falls back to `sql_variant` **Severity**: Warning -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS006.md b/Documentations/diagnostics/CAERIUS006.md index b7511ad..96f1b83 100644 --- a/Documentations/diagnostics/CAERIUS006.md +++ b/Documentations/diagnostics/CAERIUS006.md @@ -1,7 +1,7 @@ # CAERIUS006 — Generator target shape is not supported **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer ## Cause diff --git a/Documentations/diagnostics/CAERIUS200.md b/Documentations/diagnostics/CAERIUS200.md new file mode 100644 index 0000000..3672dc2 --- /dev/null +++ b/Documentations/diagnostics/CAERIUS200.md @@ -0,0 +1,23 @@ +# CAERIUS200 — AutoContracts manifest is missing + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The AutoContracts source generator did not receive a `caerius.contracts.json` manifest through +Roslyn `AdditionalFiles`. + +The generator reads only `AdditionalFiles`; it does not connect to SQL Server during compilation. +The CLI/MSBuild phase is responsible for reading SQL Server metadata in read-only mode and creating +the manifest before compilation consumes it. + +## How to fix + +Ensure the manifest is generated and included in the project as an `AdditionalFiles` item. + +With the MSBuild package, the default manifest path is `$(ProjectDir)caerius.contracts.json`. +Override `CaeriusContractsOutput` only when the file intentionally lives elsewhere. + +When using the CLI directly, run `pull --output ` or place `caerius.contracts.json` in the +current working directory, then include that file in the consuming project. diff --git a/Documentations/diagnostics/CAERIUS201.md b/Documentations/diagnostics/CAERIUS201.md new file mode 100644 index 0000000..dcbea7e --- /dev/null +++ b/Documentations/diagnostics/CAERIUS201.md @@ -0,0 +1,17 @@ +# CAERIUS201 — Stored procedure could not be found + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The manifest references a stored procedure that does not exist in the inspected SQL Server database. +This is detected by the CLI/MSBuild SQL Server metadata read, not by the Roslyn generator alone. + +## How to fix + +Check the procedure schema and name in `caerius.contracts.json`, then run `Verify` against the same +database shape expected by the application. + +If the procedure was intentionally renamed or removed, run `Pull` to refresh the manifest and review +the generated contract changes. diff --git a/Documentations/diagnostics/CAERIUS202.md b/Documentations/diagnostics/CAERIUS202.md new file mode 100644 index 0000000..3d0bb0a --- /dev/null +++ b/Documentations/diagnostics/CAERIUS202.md @@ -0,0 +1,18 @@ +# CAERIUS202 — Referenced TVP type is missing + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A stored procedure parameter references a table-valued parameter type that was not found in the +inspected SQL Server database. AutoContracts needs that type metadata to generate the typed TVP +contract. + +## How to fix + +Create the expected user-defined table type, correct the schema-qualified TVP name, or update the +procedure so it references the intended type. + +After an intentional database change, run `Pull` so `caerius.contracts.json` contains the current +TVP shape before the source generator reads it as `AdditionalFiles`. diff --git a/Documentations/diagnostics/CAERIUS203.md b/Documentations/diagnostics/CAERIUS203.md new file mode 100644 index 0000000..acaf4ab --- /dev/null +++ b/Documentations/diagnostics/CAERIUS203.md @@ -0,0 +1,15 @@ +# CAERIUS203 — TVP SQL type is not supported + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A column in a referenced table-valued parameter uses a SQL Server type that AutoContracts does not +support for generated contracts. The type may be valid SQL Server, but it cannot be represented +safely by the generated TVP mapper. + +## How to fix + +Change the TVP column to a supported SQL Server type, expose a supported boundary type, or exclude +the procedure until the type can be represented safely. Then refresh the manifest with `Pull`. diff --git a/Documentations/diagnostics/CAERIUS204.md b/Documentations/diagnostics/CAERIUS204.md new file mode 100644 index 0000000..f83d3ad --- /dev/null +++ b/Documentations/diagnostics/CAERIUS204.md @@ -0,0 +1,18 @@ +# CAERIUS204 — First projection cannot be determined + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +AutoContracts could not determine the first result projection returned by the stored procedure from +SQL Server metadata. + +## How to fix + +Make the first result shape explicit and stable. Avoid branches, temporary dynamic SQL, temporary +tables whose shape cannot be discovered, or early returns that prevent SQL Server metadata discovery +from identifying the first projection. + +If the procedure is command-only, remove it from read-contract generation instead of trying to emit a +DTO contract. diff --git a/Documentations/diagnostics/CAERIUS205.md b/Documentations/diagnostics/CAERIUS205.md new file mode 100644 index 0000000..5efd0e8 --- /dev/null +++ b/Documentations/diagnostics/CAERIUS205.md @@ -0,0 +1,17 @@ +# CAERIUS205 — Stored procedure has no result set + +**Severity**: Warning +**Category**: CaeriusNet.AutoContracts + +## Cause + +The stored procedure was included in AutoContracts read-contract generation, but SQL Server metadata +reported no result set. + +## How to fix + +Return a stable result set from the procedure, or remove it from AutoContracts read-only contract +generation if it is command-only. + +Use `Verify` in validation to catch accidental changes where a read procedure stops exposing its +first projection. diff --git a/Documentations/diagnostics/CAERIUS206.md b/Documentations/diagnostics/CAERIUS206.md new file mode 100644 index 0000000..1415d1c --- /dev/null +++ b/Documentations/diagnostics/CAERIUS206.md @@ -0,0 +1,18 @@ +# CAERIUS206 — OUTPUT parameters are not supported + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The stored procedure declares one or more `OUTPUT` parameters. AutoContracts currently generates +read-only stored procedure contracts from input parameters and result sets; `OUTPUT` parameters are +not part of that generated surface. + +## How to fix + +Return values through the result set instead of `OUTPUT` parameters, or keep the procedure outside +AutoContracts read-only contract generation. + +After changing the procedure contract, run `Pull` and review the refreshed +`caerius.contracts.json`. diff --git a/Documentations/diagnostics/CAERIUS207.md b/Documentations/diagnostics/CAERIUS207.md new file mode 100644 index 0000000..f48d33e --- /dev/null +++ b/Documentations/diagnostics/CAERIUS207.md @@ -0,0 +1,16 @@ +# CAERIUS207 — SQL type cannot be mapped + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A procedure parameter or projected column uses a SQL Server type that AutoContracts cannot map to a +CLR contract type. The generated contract would otherwise expose an unsafe or ambiguous C# type. + +## How to fix + +Use a supported SQL Server type, cast the value in the first projection, or introduce a database +boundary shape that maps cleanly. + +Run `Pull` after the SQL change so the manifest and generated contract agree. diff --git a/Documentations/diagnostics/CAERIUS208.md b/Documentations/diagnostics/CAERIUS208.md new file mode 100644 index 0000000..eb9d800 --- /dev/null +++ b/Documentations/diagnostics/CAERIUS208.md @@ -0,0 +1,17 @@ +# CAERIUS208 — Nullable column violates policy + +**Severity**: Policy-dependent +**Category**: CaeriusNet.AutoContracts + +## Cause + +SQL Server metadata marks a projected column as nullable, and the configured AutoContracts +nullability policy treats that column as invalid. + +## How to fix + +Make the projection non-nullable, adjust the contract to accept null values, or change the +nullability policy if nullable columns are intentional. + +When nullability changes are intentional, refresh `caerius.contracts.json` with `Pull` and review the +generated nullable CLR type before committing. diff --git a/Documentations/diagnostics/CAERIUS209.md b/Documentations/diagnostics/CAERIUS209.md new file mode 100644 index 0000000..5118d51 --- /dev/null +++ b/Documentations/diagnostics/CAERIUS209.md @@ -0,0 +1,18 @@ +# CAERIUS209 — Contract hash differs during Verify + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +`Verify` computed a contract hash from current SQL Server metadata that differs from the hash stored +in `caerius.contracts.json`. + +## How to fix + +If the database change is intentional, run `Pull`, review the updated `caerius.contracts.json`, and +commit it with the application change. + +If the change is not intentional, restore the expected SQL Server contract and rerun `Verify`. The +verification path is read-only: it reports the mismatch but does not update SQL Server or the +manifest. diff --git a/Documentations/diagnostics/CAERIUS210.md b/Documentations/diagnostics/CAERIUS210.md new file mode 100644 index 0000000..fe63b03 --- /dev/null +++ b/Documentations/diagnostics/CAERIUS210.md @@ -0,0 +1,17 @@ +# CAERIUS210 — Stored procedure is probably incompatible + +**Severity**: Warning +**Category**: CaeriusNet.AutoContracts + +## Cause + +AutoContracts detected procedure metadata that is likely incompatible with read-only contract +generation, but the database did not expose enough detail for a more specific diagnostic. + +## How to fix + +Review the procedure shape for unsupported parameters, unsupported SQL types, dynamic SQL, temporary +objects, or branches that change the first result set. + +Prefer making the first projection explicit and metadata-discoverable. If the procedure is not meant +to produce a typed read contract, remove it from AutoContracts generation. diff --git a/Documentations/docs/.vitepress/config.mts b/Documentations/docs/.vitepress/config.mts index 22f43a9..51386aa 100644 --- a/Documentations/docs/.vitepress/config.mts +++ b/Documentations/docs/.vitepress/config.mts @@ -22,6 +22,7 @@ export default defineConfig({ items: [ { text: 'DTO Mapping', link: '/documentation/dto-mapping' }, { text: 'Source Generators', link: '/documentation/source-generators' }, + { text: 'AutoContracts', link: '/documentation/auto-contracts' }, { text: 'Reading Data', link: '/documentation/reading-data' }, { text: 'Writing Data', link: '/documentation/writing-data' }, { text: 'Table-Valued Parameters', link: '/documentation/tvp' }, @@ -61,6 +62,7 @@ export default defineConfig({ items: [ { text: 'DTO Mapping', link: '/documentation/dto-mapping' }, { text: 'Source Generators', link: '/documentation/source-generators' }, + { text: 'AutoContracts', link: '/documentation/auto-contracts' }, { text: 'Table-Valued Parameters', link: '/documentation/tvp' } ] }, @@ -97,7 +99,19 @@ export default defineConfig({ { text: 'CAERIUS002', link: '/diagnostics/CAERIUS002' }, { text: 'CAERIUS003', link: '/diagnostics/CAERIUS003' }, { text: 'CAERIUS004', link: '/diagnostics/CAERIUS004' }, - { text: 'CAERIUS005', link: '/diagnostics/CAERIUS005' } + { text: 'CAERIUS005', link: '/diagnostics/CAERIUS005' }, + { text: 'CAERIUS006', link: '/diagnostics/CAERIUS006' }, + { text: 'CAERIUS200', link: '/diagnostics/CAERIUS200' }, + { text: 'CAERIUS201', link: '/diagnostics/CAERIUS201' }, + { text: 'CAERIUS202', link: '/diagnostics/CAERIUS202' }, + { text: 'CAERIUS203', link: '/diagnostics/CAERIUS203' }, + { text: 'CAERIUS204', link: '/diagnostics/CAERIUS204' }, + { text: 'CAERIUS205', link: '/diagnostics/CAERIUS205' }, + { text: 'CAERIUS206', link: '/diagnostics/CAERIUS206' }, + { text: 'CAERIUS207', link: '/diagnostics/CAERIUS207' }, + { text: 'CAERIUS208', link: '/diagnostics/CAERIUS208' }, + { text: 'CAERIUS209', link: '/diagnostics/CAERIUS209' }, + { text: 'CAERIUS210', link: '/diagnostics/CAERIUS210' } ] }, { diff --git a/Documentations/docs/diagnostics/CAERIUS001.md b/Documentations/docs/diagnostics/CAERIUS001.md index f81e632..d6d9079 100644 --- a/Documentations/docs/diagnostics/CAERIUS001.md +++ b/Documentations/docs/diagnostics/CAERIUS001.md @@ -1,7 +1,7 @@ # CAERIUS001 — Type must be `sealed` **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer **Applies to**: `[GenerateDto]`, `[GenerateTvp]` ## Cause diff --git a/Documentations/docs/diagnostics/CAERIUS002.md b/Documentations/docs/diagnostics/CAERIUS002.md index 48f492b..d480313 100644 --- a/Documentations/docs/diagnostics/CAERIUS002.md +++ b/Documentations/docs/diagnostics/CAERIUS002.md @@ -1,7 +1,7 @@ # CAERIUS002 — Type must be `partial` **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer **Applies to**: `[GenerateDto]`, `[GenerateTvp]` ## Cause diff --git a/Documentations/docs/diagnostics/CAERIUS003.md b/Documentations/docs/diagnostics/CAERIUS003.md index c37c490..89ba287 100644 --- a/Documentations/docs/diagnostics/CAERIUS003.md +++ b/Documentations/docs/diagnostics/CAERIUS003.md @@ -1,7 +1,7 @@ # CAERIUS003 — Type must declare a primary constructor **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer **Applies to**: `[GenerateDto]`, `[GenerateTvp]` ## Cause diff --git a/Documentations/docs/diagnostics/CAERIUS004.md b/Documentations/docs/diagnostics/CAERIUS004.md index f16c8fa..cfc6c50 100644 --- a/Documentations/docs/diagnostics/CAERIUS004.md +++ b/Documentations/docs/diagnostics/CAERIUS004.md @@ -1,7 +1,7 @@ # CAERIUS004 — `TvpName` must not be empty **Severity**: Error -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer **Applies to**: `[GenerateTvp]` ## Cause diff --git a/Documentations/docs/diagnostics/CAERIUS005.md b/Documentations/docs/diagnostics/CAERIUS005.md index 765acdd..83c0a1f 100644 --- a/Documentations/docs/diagnostics/CAERIUS005.md +++ b/Documentations/docs/diagnostics/CAERIUS005.md @@ -1,7 +1,7 @@ # CAERIUS005 — Unmapped CLR type falls back to `sql_variant` **Severity**: Warning -**Category**: CaeriusNet.Generator +**Category**: CaeriusNet.Analyzer **Applies to**: `[GenerateDto]`, `[GenerateTvp]` ## Cause diff --git a/Documentations/docs/diagnostics/CAERIUS006.md b/Documentations/docs/diagnostics/CAERIUS006.md new file mode 100644 index 0000000..1c4b069 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS006.md @@ -0,0 +1,21 @@ +# CAERIUS006 — Generator target shape is not supported + +**Severity**: Error +**Category**: CaeriusNet.Analyzer + +## Cause + +A type decorated with `[GenerateDto]` or `[GenerateTvp]` is either a generic type or a nested +type. CaeriusNet generators currently only support non-generic, top-level types. + +## Why + +The source generators emit companion `partial` declarations that must be placed in a well-known +namespace without additional type parameters. Generic and nested types would require reproducing +the enclosing type hierarchy and all type parameters in the generated output, which is not +currently supported. + +## How to fix + +Move the type to the top level and remove any generic type parameters. + diff --git a/Documentations/docs/diagnostics/CAERIUS200.md b/Documentations/docs/diagnostics/CAERIUS200.md new file mode 100644 index 0000000..3672dc2 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS200.md @@ -0,0 +1,23 @@ +# CAERIUS200 — AutoContracts manifest is missing + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The AutoContracts source generator did not receive a `caerius.contracts.json` manifest through +Roslyn `AdditionalFiles`. + +The generator reads only `AdditionalFiles`; it does not connect to SQL Server during compilation. +The CLI/MSBuild phase is responsible for reading SQL Server metadata in read-only mode and creating +the manifest before compilation consumes it. + +## How to fix + +Ensure the manifest is generated and included in the project as an `AdditionalFiles` item. + +With the MSBuild package, the default manifest path is `$(ProjectDir)caerius.contracts.json`. +Override `CaeriusContractsOutput` only when the file intentionally lives elsewhere. + +When using the CLI directly, run `pull --output ` or place `caerius.contracts.json` in the +current working directory, then include that file in the consuming project. diff --git a/Documentations/docs/diagnostics/CAERIUS201.md b/Documentations/docs/diagnostics/CAERIUS201.md new file mode 100644 index 0000000..dcbea7e --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS201.md @@ -0,0 +1,17 @@ +# CAERIUS201 — Stored procedure could not be found + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The manifest references a stored procedure that does not exist in the inspected SQL Server database. +This is detected by the CLI/MSBuild SQL Server metadata read, not by the Roslyn generator alone. + +## How to fix + +Check the procedure schema and name in `caerius.contracts.json`, then run `Verify` against the same +database shape expected by the application. + +If the procedure was intentionally renamed or removed, run `Pull` to refresh the manifest and review +the generated contract changes. diff --git a/Documentations/docs/diagnostics/CAERIUS202.md b/Documentations/docs/diagnostics/CAERIUS202.md new file mode 100644 index 0000000..3d0bb0a --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS202.md @@ -0,0 +1,18 @@ +# CAERIUS202 — Referenced TVP type is missing + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A stored procedure parameter references a table-valued parameter type that was not found in the +inspected SQL Server database. AutoContracts needs that type metadata to generate the typed TVP +contract. + +## How to fix + +Create the expected user-defined table type, correct the schema-qualified TVP name, or update the +procedure so it references the intended type. + +After an intentional database change, run `Pull` so `caerius.contracts.json` contains the current +TVP shape before the source generator reads it as `AdditionalFiles`. diff --git a/Documentations/docs/diagnostics/CAERIUS203.md b/Documentations/docs/diagnostics/CAERIUS203.md new file mode 100644 index 0000000..acaf4ab --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS203.md @@ -0,0 +1,15 @@ +# CAERIUS203 — TVP SQL type is not supported + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A column in a referenced table-valued parameter uses a SQL Server type that AutoContracts does not +support for generated contracts. The type may be valid SQL Server, but it cannot be represented +safely by the generated TVP mapper. + +## How to fix + +Change the TVP column to a supported SQL Server type, expose a supported boundary type, or exclude +the procedure until the type can be represented safely. Then refresh the manifest with `Pull`. diff --git a/Documentations/docs/diagnostics/CAERIUS204.md b/Documentations/docs/diagnostics/CAERIUS204.md new file mode 100644 index 0000000..f83d3ad --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS204.md @@ -0,0 +1,18 @@ +# CAERIUS204 — First projection cannot be determined + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +AutoContracts could not determine the first result projection returned by the stored procedure from +SQL Server metadata. + +## How to fix + +Make the first result shape explicit and stable. Avoid branches, temporary dynamic SQL, temporary +tables whose shape cannot be discovered, or early returns that prevent SQL Server metadata discovery +from identifying the first projection. + +If the procedure is command-only, remove it from read-contract generation instead of trying to emit a +DTO contract. diff --git a/Documentations/docs/diagnostics/CAERIUS205.md b/Documentations/docs/diagnostics/CAERIUS205.md new file mode 100644 index 0000000..5efd0e8 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS205.md @@ -0,0 +1,17 @@ +# CAERIUS205 — Stored procedure has no result set + +**Severity**: Warning +**Category**: CaeriusNet.AutoContracts + +## Cause + +The stored procedure was included in AutoContracts read-contract generation, but SQL Server metadata +reported no result set. + +## How to fix + +Return a stable result set from the procedure, or remove it from AutoContracts read-only contract +generation if it is command-only. + +Use `Verify` in validation to catch accidental changes where a read procedure stops exposing its +first projection. diff --git a/Documentations/docs/diagnostics/CAERIUS206.md b/Documentations/docs/diagnostics/CAERIUS206.md new file mode 100644 index 0000000..1415d1c --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS206.md @@ -0,0 +1,18 @@ +# CAERIUS206 — OUTPUT parameters are not supported + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +The stored procedure declares one or more `OUTPUT` parameters. AutoContracts currently generates +read-only stored procedure contracts from input parameters and result sets; `OUTPUT` parameters are +not part of that generated surface. + +## How to fix + +Return values through the result set instead of `OUTPUT` parameters, or keep the procedure outside +AutoContracts read-only contract generation. + +After changing the procedure contract, run `Pull` and review the refreshed +`caerius.contracts.json`. diff --git a/Documentations/docs/diagnostics/CAERIUS207.md b/Documentations/docs/diagnostics/CAERIUS207.md new file mode 100644 index 0000000..f48d33e --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS207.md @@ -0,0 +1,16 @@ +# CAERIUS207 — SQL type cannot be mapped + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +A procedure parameter or projected column uses a SQL Server type that AutoContracts cannot map to a +CLR contract type. The generated contract would otherwise expose an unsafe or ambiguous C# type. + +## How to fix + +Use a supported SQL Server type, cast the value in the first projection, or introduce a database +boundary shape that maps cleanly. + +Run `Pull` after the SQL change so the manifest and generated contract agree. diff --git a/Documentations/docs/diagnostics/CAERIUS208.md b/Documentations/docs/diagnostics/CAERIUS208.md new file mode 100644 index 0000000..eb9d800 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS208.md @@ -0,0 +1,17 @@ +# CAERIUS208 — Nullable column violates policy + +**Severity**: Policy-dependent +**Category**: CaeriusNet.AutoContracts + +## Cause + +SQL Server metadata marks a projected column as nullable, and the configured AutoContracts +nullability policy treats that column as invalid. + +## How to fix + +Make the projection non-nullable, adjust the contract to accept null values, or change the +nullability policy if nullable columns are intentional. + +When nullability changes are intentional, refresh `caerius.contracts.json` with `Pull` and review the +generated nullable CLR type before committing. diff --git a/Documentations/docs/diagnostics/CAERIUS209.md b/Documentations/docs/diagnostics/CAERIUS209.md new file mode 100644 index 0000000..5118d51 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS209.md @@ -0,0 +1,18 @@ +# CAERIUS209 — Contract hash differs during Verify + +**Severity**: Error +**Category**: CaeriusNet.AutoContracts + +## Cause + +`Verify` computed a contract hash from current SQL Server metadata that differs from the hash stored +in `caerius.contracts.json`. + +## How to fix + +If the database change is intentional, run `Pull`, review the updated `caerius.contracts.json`, and +commit it with the application change. + +If the change is not intentional, restore the expected SQL Server contract and rerun `Verify`. The +verification path is read-only: it reports the mismatch but does not update SQL Server or the +manifest. diff --git a/Documentations/docs/diagnostics/CAERIUS210.md b/Documentations/docs/diagnostics/CAERIUS210.md new file mode 100644 index 0000000..fe63b03 --- /dev/null +++ b/Documentations/docs/diagnostics/CAERIUS210.md @@ -0,0 +1,17 @@ +# CAERIUS210 — Stored procedure is probably incompatible + +**Severity**: Warning +**Category**: CaeriusNet.AutoContracts + +## Cause + +AutoContracts detected procedure metadata that is likely incompatible with read-only contract +generation, but the database did not expose enough detail for a more specific diagnostic. + +## How to fix + +Review the procedure shape for unsupported parameters, unsupported SQL types, dynamic SQL, temporary +objects, or branches that change the first result set. + +Prefer making the first projection explicit and metadata-discoverable. If the procedure is not meant +to produce a typed read contract, remove it from AutoContracts generation. diff --git a/Documentations/docs/diagnostics/index.md b/Documentations/docs/diagnostics/index.md index a44d364..adeda84 100644 --- a/Documentations/docs/diagnostics/index.md +++ b/Documentations/docs/diagnostics/index.md @@ -1,8 +1,8 @@ # Diagnostic rules -CaeriusNet analyzers validate `[GenerateDto]` and `[GenerateTvp]` contracts at compile time. Diagnostics are emitted by the analyzer included with the `CaeriusNet` package and appear in IDE builds and `dotnet build`. +CaeriusNet analyzers validate `[GenerateDto]`, `[GenerateTvp]`, and AutoContracts manifest inputs at compile time. Diagnostics are emitted by the analyzer included with the `CaeriusNet` package and appear in IDE builds and `dotnet build`. -Use these pages as the rule reference for generated DTO and TVP mapper issues. +Use these pages as the rule reference for generated DTO, TVP mapper, and AutoContracts manifest issues. | ID | Severity | Summary | |---|---|---| @@ -11,9 +11,21 @@ Use these pages as the rule reference for generated DTO and TVP mapper issues. | [CAERIUS003](./CAERIUS003) | Error | Type must declare a primary constructor | | [CAERIUS004](./CAERIUS004) | Error | `TvpName` must not be empty | | [CAERIUS005](./CAERIUS005) | Warning | Unmapped CLR type falls back to `sql_variant` | +| [CAERIUS006](./CAERIUS006) | Error | Generator target shape is not supported | +| [CAERIUS200](./CAERIUS200) | Error | AutoContracts manifest is missing | +| [CAERIUS201](./CAERIUS201) | Error | Stored procedure could not be found | +| [CAERIUS202](./CAERIUS202) | Error | Referenced TVP type is missing | +| [CAERIUS203](./CAERIUS203) | Error | TVP SQL type is not supported | +| [CAERIUS204](./CAERIUS204) | Error | First projection cannot be determined | +| [CAERIUS205](./CAERIUS205) | Warning | Stored procedure has no result set | +| [CAERIUS206](./CAERIUS206) | Error | `OUTPUT` parameters are not supported | +| [CAERIUS207](./CAERIUS207) | Error | SQL type cannot be mapped | +| [CAERIUS208](./CAERIUS208) | Policy-dependent | Nullable column violates policy | +| [CAERIUS209](./CAERIUS209) | Error | Contract hash differs during Verify | +| [CAERIUS210](./CAERIUS210) | Warning | Stored procedure is probably incompatible | ::: tip Build policy -Keep error diagnostics fixed in source. Treat warning suppressions as design decisions and prefer documenting them near the affected type. +Keep error diagnostics fixed in source. Treat warning suppressions as design decisions and prefer documenting them near the affected type or AutoContracts manifest entry. ::: See also the higher-level [Compiler Diagnostics](/documentation/diagnostics) guide for suppression and severity configuration. diff --git a/Documentations/docs/documentation/auto-contracts.md b/Documentations/docs/documentation/auto-contracts.md new file mode 100644 index 0000000..de3d809 --- /dev/null +++ b/Documentations/docs/documentation/auto-contracts.md @@ -0,0 +1,63 @@ +# AutoContracts + +AutoContracts keeps stored procedure call contracts aligned with SQL Server metadata. It is designed as a read-only safety layer: CaeriusNet reads metadata from SQL Server, but does not create, update, or delete database objects and does not change application data. + +## What it tracks + +AutoContracts records the contract shape used by typed stored procedure calls: + +- procedure identity +- parameter names, SQL types, direction, and nullability +- result-set columns when SQL Server can expose them through metadata +- the typed call surface represented by `StoredProcedureParametersBuilder` + +Generated contract artifacts are marked with `AutoContractGenerateDto`, `AutoContractGenerateTvp`, and +`AutoContractGenerateProcedure`. Use these exact AutoContracts marker names in public +documentation, samples, and generated API names. + +## Manifest location + +The snapshot is stored in `caerius.contracts.json`. + +When AutoContracts runs through MSBuild, the default path is +`$(ProjectDir)caerius.contracts.json`. Override `CaeriusContractsOutput` when a project needs a +different path. The build targets pass that same path to the CLI and include it as an +`AdditionalFiles` item for the source generator. + +When the CLI is run directly, `pull` writes to `--output` when provided, then `--manifest` when +provided, and otherwise `caerius.contracts.json` in the current working directory. `verify` reads +from `--manifest` when provided, then `--output` when provided, and otherwise +`caerius.contracts.json` in the current working directory. + +## Build responsibilities + +AutoContracts has two separate execution phases: + +- The CLI/MSBuild phase connects to SQL Server in read-only mode, reads metadata, and writes or + verifies `caerius.contracts.json`. +- The Roslyn source generator does not connect to SQL Server. It only reads the + `caerius.contracts.json` file supplied through `AdditionalFiles`. + +## Modes + +`CaeriusContractsMode` controls how the snapshot is used: + +| Mode | Behavior | +|---|---| +| `Pull` | Reads SQL Server metadata and refreshes `caerius.contracts.json`. Use this after an intentional stored procedure contract change. | +| `Verify` | Reads SQL Server metadata and compares it with `caerius.contracts.json`. Use this in validation workflows to detect drift. | +| `Off` | Skips AutoContracts work. Use this when contract checks are not needed for a run. | + +## Recommended workflow + +1. Change the stored procedure contract intentionally. +2. Run AutoContracts with `Pull`. +3. Review the updated `caerius.contracts.json`. +4. Commit the application change and the refreshed contract snapshot together. +5. Run AutoContracts with `Verify` in validation to catch unexpected SQL Server drift. + +## Read-only guarantees + +AutoContracts uses SQL Server metadata reads only. `Pull` updates `caerius.contracts.json` in the application workspace; it does not apply SQL changes back to the database. `Verify` reports mismatches and leaves both SQL Server and the snapshot unchanged. + +Keep `Off` available for local scenarios where SQL Server is unavailable, but prefer `Verify` wherever a real database is part of the validation path. diff --git a/Documentations/docs/documentation/diagnostics.md b/Documentations/docs/documentation/diagnostics.md index 4306914..bcc4100 100644 --- a/Documentations/docs/documentation/diagnostics.md +++ b/Documentations/docs/documentation/diagnostics.md @@ -1,6 +1,6 @@ # Compiler Diagnostics -CaeriusNet's Roslyn analyzer emits **compile-time diagnostics** for `[GenerateDto]` and `[GenerateTvp]` contract issues. The analyzer ships inside the `CaeriusNet` NuGet package; no extra reference is required. +CaeriusNet's Roslyn analyzer emits **compile-time diagnostics** for `[GenerateDto]`, `[GenerateTvp]`, and AutoContracts manifest issues. The analyzer ships inside the `CaeriusNet` NuGet package; no extra reference is required. Use this page to decide whether a diagnostic must be fixed, can be suppressed, or should be escalated in CI. @@ -13,6 +13,18 @@ Use this page to decide whether a diagnostic must be fixed, can be suppressed, o | [CAERIUS003](/diagnostics/CAERIUS003) | Error | Primary constructor required | The type does not declare a primary constructor (or the constructor has no parameters) | | [CAERIUS004](/diagnostics/CAERIUS004) | Error | `[GenerateTvp]` requires a non-empty `TvpName` | The attribute sets `TvpName` to an empty or whitespace-only string | | [CAERIUS005](/diagnostics/CAERIUS005) | Warning | Unmapped CLR type falls back to `sql_variant` | A constructor parameter has a type with no native SQL Server mapping | +| [CAERIUS006](/diagnostics/CAERIUS006) | Error | Generator target shape is not supported | A generator target cannot be represented safely | +| [CAERIUS200](/diagnostics/CAERIUS200) | Error | AutoContracts manifest is missing | AutoContracts generation is enabled, but no `caerius.contracts.json` `AdditionalFiles` item is available | +| [CAERIUS201](/diagnostics/CAERIUS201) | Error | Stored procedure could not be found | The manifest references a procedure that is missing from the inspected SQL Server database | +| [CAERIUS202](/diagnostics/CAERIUS202) | Error | Referenced TVP type is missing | A procedure parameter references a TVP type that is missing from SQL Server metadata | +| [CAERIUS203](/diagnostics/CAERIUS203) | Error | TVP SQL type is not supported | A referenced TVP column uses a SQL Server type AutoContracts cannot generate safely | +| [CAERIUS204](/diagnostics/CAERIUS204) | Error | First projection cannot be determined | SQL Server cannot expose a stable first result set for the procedure | +| [CAERIUS205](/diagnostics/CAERIUS205) | Warning | Stored procedure has no result set | The procedure is included for read-contract generation but SQL Server reports no result set | +| [CAERIUS206](/diagnostics/CAERIUS206) | Error | `OUTPUT` parameters are not supported | A procedure declares one or more `OUTPUT` parameters | +| [CAERIUS207](/diagnostics/CAERIUS207) | Error | SQL type cannot be mapped | A procedure parameter or projected column uses an unmappable SQL Server type | +| [CAERIUS208](/diagnostics/CAERIUS208) | Policy-dependent | Nullable column violates policy | SQL Server metadata marks a projected column nullable and the active policy rejects it | +| [CAERIUS209](/diagnostics/CAERIUS209) | Error | Contract hash differs during Verify | `Verify` found drift between SQL Server metadata and `caerius.contracts.json` | +| [CAERIUS210](/diagnostics/CAERIUS210) | Warning | Stored procedure is probably incompatible | Metadata discovery found an incompatible shape without enough detail for a narrower diagnostic | ## Severity levels @@ -76,7 +88,9 @@ dotnet_diagnostic.CAERIUS005.severity = error ``` ::: tip CI enforcement -Escalating `CAERIUS005` to an error in your CI `.editorconfig` prevents an accidental `sql_variant` fallback from reaching production. +Escalating `CAERIUS005` and AutoContracts warnings such as `CAERIUS205`, `CAERIUS208`, or +`CAERIUS210` in your CI `.editorconfig` prevents accidental contract drift or ambiguous metadata +from reaching production. ::: ## Troubleshooting diff --git a/Documentations/docs/documentation/source-generators.md b/Documentations/docs/documentation/source-generators.md index 1caad45..f911167 100644 --- a/Documentations/docs/documentation/source-generators.md +++ b/Documentations/docs/documentation/source-generators.md @@ -1,6 +1,6 @@ # Source Generators -CaeriusNet ships two Roslyn **incremental source generators** that eliminate mapping boilerplate at compile time. They run as part of your build, produce zero runtime overhead, and are fully AOT- and trim-compatible. +CaeriusNet ships Roslyn **incremental source generators** that eliminate mapping boilerplate at compile time. They run as part of your build, produce zero runtime overhead, and are fully AOT- and trim-compatible. ## Overview @@ -8,8 +8,14 @@ CaeriusNet ships two Roslyn **incremental source generators** that eliminate map |---|---|---| | `DtoSourceGenerator` | `[GenerateDto]` | `ISpMapper` | | `TvpSourceGenerator` | `[GenerateTvp]` | `ITvpMapper` | +| `AutoContractsSourceGenerator` | `caerius.contracts.json` supplied as `AdditionalFiles` | typed stored procedure descriptors and DTO/TVP contracts | -Both generators target **sealed partial records or classes** with a primary constructor. The `partial` keyword lets the generator add the interface implementation as a second declaration alongside your type. Constraints are enforced at compile time by the [CaeriusNet analyzer](/documentation/diagnostics) (`CAERIUS001`–`CAERIUS005`). +The DTO and TVP generators target **sealed partial records or classes** with a primary constructor. The `partial` keyword lets the generator add the interface implementation as a second declaration alongside your type. Constraints are enforced at compile time by the [CaeriusNet analyzer](/documentation/diagnostics) (`CAERIUS001` through `CAERIUS006`). + +The AutoContracts generator is different: it does not connect to SQL Server and it does not inspect +the database during compilation. It reads only the `caerius.contracts.json` manifest provided as a +Roslyn `AdditionalFiles` item. The SQL Server metadata read happens earlier in the CLI/MSBuild +AutoContracts phase. ## `[GenerateDto]` — DTO mapper diff --git a/Documentations/docs/documentation/usage.md b/Documentations/docs/documentation/usage.md index f112601..f3426d8 100644 --- a/Documentations/docs/documentation/usage.md +++ b/Documentations/docs/documentation/usage.md @@ -19,6 +19,7 @@ public sealed record UserRepository(ICaeriusNetDbContext DbContext) |---|---| | [DTO Mapping](/documentation/dto-mapping) | Ordinal-based mapping, nullability, special types | | [Source Generators](/documentation/source-generators) | `[GenerateDto]` and `[GenerateTvp]` | +| [AutoContracts](/documentation/auto-contracts) | SQL Server read-only contract snapshots with `caerius.contracts.json` | | [Reading Data](/documentation/reading-data) | `QueryAsIEnumerableAsync`, `FirstQueryAsync`, capacity | | [Multiple Result Sets](/documentation/multi-results) | Fetch 2–5 result sets in one round-trip | diff --git a/README.md b/README.md index 18d5658..3e15df8 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ CaeriusNetBuilder.Create(builder) Before publishing, validate the NuGet package contents and smoke-test a consumer project: ```bash -pwsh ./eng/ValidatePackage.ps1 -Configuration Release +pwsh ./.github/scripts/ValidatePackage.ps1 -Configuration Release -OutputDirectory .work/package-validation ``` Trim/AOT analyzer validation is covered by the Release build. To run it directly: diff --git a/SourceGenerators/README.md b/SourceGenerators/README.md index 64f1894..e1c62fc 100644 --- a/SourceGenerators/README.md +++ b/SourceGenerators/README.md @@ -1,23 +1,27 @@ # CaeriusNet Source Generators -Roslyn incremental source generators that emit compile-time mappers for DTOs and TVPs. Targets `netstandard2.0` (Roslyn -analyzer constraint). Uses `Microsoft.CodeAnalysis.CSharp 5.3.0`. +Roslyn incremental source generators that emit compile-time mappers for DTOs, TVPs, and SQL Server AutoContracts. +Targets `netstandard2.0` (Roslyn analyzer constraint). Uses `Microsoft.CodeAnalysis.CSharp 5.3.0`. ## Architecture ### Pipeline ``` -ForAttributeWithMetadataName → Extract (value-equatable models) → Emit (pure string transforms) +ForAttributeWithMetadataName / AdditionalTextsProvider -> Extract -> Emit ``` Each generator follows the same three-stage pipeline: -1. **Filter** — `ForAttributeWithMetadataName` selects types annotated with `[GenerateDto]` or `[GenerateTvp]`. +1. **Filter** — `ForAttributeWithMetadataName` selects types annotated with `[GenerateDto]` or `[GenerateTvp]`; + AutoContracts selects only `caerius.contracts.json` from `AdditionalTextsProvider`. 2. **Extract** — Transforms syntax/semantic model into a value-equatable model (`DtoModel` / `TvpModel`). No Roslyn types flow past this stage. 3. **Emit** — Pure function that converts the model into generated C# source via `StringBuilder`. +User-facing diagnostics are owned by `CaeriusNet.Analyzer`. Generators silently skip invalid inputs so Roslyn's +incremental pipeline stays focused on deterministic source production. + Generated files use stable, generator-specific hint names so outputs remain distinct when type names repeat across namespaces or generator kinds. @@ -32,25 +36,26 @@ namespaces or generator kinds. ## Project Structure -| File / Directory | Purpose | -|-------------------------------------------|---------------------------------------------------------------------| -| `Dto/DtoSourceGenerator.cs` | `IIncrementalGenerator` for `[GenerateDto]` — emits `ISpMapper` | -| `Dto/DtoExtractor.cs` | Extracts `DtoModel` from syntax/semantic model | -| `Dto/DtoEmitter.cs` | Emits `MapFromDataReader` source code | -| `Tvp/TvpSourceGenerator.cs` | `IIncrementalGenerator` for `[GenerateTvp]` — emits `ITvpMapper` | -| `Tvp/TvpExtractor.cs` | Extracts `TvpModel` from syntax/semantic model | -| `Tvp/TvpEmitter.cs` | Emits `MapAsSqlDataRecords` + `TvpTypeName` source code | -| `Helpers/TypeDetector.cs` | C# type → SQL type mapping and reader method resolution | -| `Helpers/ColumnExtractor.cs` | Shared column extraction from primary constructor parameters | -| `Helpers/HintNameBuilder.cs` | Stable unique hint names for generated source files | -| `Helpers/NamespaceHelper.cs` | Namespace resolution for generated files | -| `Helpers/TypeStructureValidator.cs` | Validates sealed, partial, primary constructor constraints | -| `Helpers/SqlMetaDataExpressionBuilder.cs` | Builds `SqlMetaData` constructor expressions for TVPs | -| `Models/DtoModel.cs` | Value-equatable DTO pipeline model | -| `Models/TvpModel.cs` | Value-equatable TVP pipeline model | -| `Models/ColumnModel.cs` | Describes a single primary constructor parameter | -| `Models/ColumnKind.cs` | Enum classifying parameter mapping behavior | -| `Models/EquatableArray.cs` | `ImmutableArray` wrapper with element equality | +| File / Directory | Purpose | +|-------------------------------------------------|----------------------------------------------------------------------------| +| `Dto/DtoSourceGenerator.cs` | `IIncrementalGenerator` for `[GenerateDto]` — emits `ISpMapper` | +| `Dto/DtoExtractor.cs` | Extracts `DtoModel` from syntax/semantic model | +| `Dto/DtoEmitter.cs` | Emits `MapFromDataReader` source code | +| `Tvp/TvpSourceGenerator.cs` | `IIncrementalGenerator` for `[GenerateTvp]` — emits `ITvpMapper` | +| `Tvp/TvpExtractor.cs` | Extracts `TvpModel` from syntax/semantic model | +| `Tvp/TvpEmitter.cs` | Emits `MapAsSqlDataRecords` + `TvpTypeName` source code | +| `AutoContracts/AutoContractsSourceGenerator.cs` | `IIncrementalGenerator` for `caerius.contracts.json` via `AdditionalFiles` | +| `Helpers/TypeDetector.cs` | C# type → SQL type mapping and reader method resolution | +| `Helpers/ColumnExtractor.cs` | Shared column extraction from primary constructor parameters | +| `Helpers/HintNameBuilder.cs` | Stable unique hint names for generated source files | +| `Helpers/NamespaceHelper.cs` | Namespace resolution for generated files | +| `Helpers/TypeStructureValidator.cs` | Validates sealed, partial, primary constructor constraints | +| `Helpers/SqlMetaDataExpressionBuilder.cs` | Builds `SqlMetaData` constructor expressions for TVPs | +| `Models/DtoModel.cs` | Value-equatable DTO pipeline model | +| `Models/TvpModel.cs` | Value-equatable TVP pipeline model | +| `Models/ColumnModel.cs` | Describes a single primary constructor parameter | +| `Models/ColumnKind.cs` | Enum classifying parameter mapping behavior | +| `Models/EquatableArray.cs` | `ImmutableArray` wrapper with element equality | ## Supported Type Mappings @@ -90,7 +95,7 @@ CAERIUS005 on those parameters. | CAERIUS005 | Warning | No native SQL mapping → `sql_variant` fallback | User-facing diagnostics are emitted by `CaeriusNet.Analyzer`, not by the incremental generators themselves. All -diagnostics use category `CaeriusNet.Generator` and link to +diagnostics link to `https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/`. ## Generated Code Features diff --git a/Tests/README.md b/Tests/README.md index a691729..cf92b31 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -2,16 +2,18 @@ ## Overview -Three test projects cover unit behavior, source generator verification, and end-to-end integration tests against a real -SQL Server instance. +Test projects cover unit behavior, analyzer diagnostics, source generator verification, packaging, and end-to-end +integration tests against a real SQL Server instance. ## Test Projects | Project | Type | Count | Framework | |-----------------------------|-------------|-------|------------------------| -| CaeriusNet.Tests | Unit | ~142 | xUnit | -| CaeriusNet.Generator.Tests | Generator | ~135 | xUnit + Roslyn Testing | -| CaeriusNet.IntegrationTests | Integration | ~48 | xUnit + Testcontainers | +| CaeriusNet.Tests | Unit | ~186 | xUnit | +| CaeriusNet.Analyzer.Tests | Analyzer | ~23 | xUnit + Roslyn | +| CaeriusNet.Generator.Tests | Generator | ~149 | xUnit + Roslyn Testing | +| CaeriusNet.IntegrationTests | Integration | ~51 | xUnit + Testcontainers | +| CaeriusNet.Packaging.Tests | Packaging | ~4 | xUnit + local NuGet | ## Prerequisites @@ -59,6 +61,12 @@ dotnet test Tests/CaeriusNet.Tests --configuration Release dotnet test Tests/CaeriusNet.Generator.Tests --configuration Release ``` +### Analyzer tests only + +```bash +dotnet test Tests/CaeriusNet.Analyzer.Tests --configuration Release +``` + ### Integration tests ```bash @@ -89,11 +97,23 @@ dotnet test CaeriusNet.slnx --configuration Release |-------------|-------------------------------------------------------------------------------------------------| | DTO | Type mappings, nullable members, special conversions, `Half`, ordinal constants, and attributes | | TVP | Supported SQL types, multi-column generation, nullable members, and `class` generation | -| Diagnostics | `CAERIUS001` through `CAERIUS005` diagnostics | | Helpers | `TypeDetector` and `ColumnExtractor` helper logic | These tests use `CSharpSourceGeneratorTest` to compile source in memory and verify generated output. +### Analyzer tests (`CaeriusNet.Analyzer.Tests`) + +| Category | What is tested | +|---------------|-----------------------------------------------------| +| Attributes | `CAERIUS001` through `CAERIUS006` target diagnostics | +| AutoContracts | `CAERIUS200` through `CAERIUS210` manifest diagnostics | + +### Packaging tests (`CaeriusNet.Packaging.Tests`) + +| Category | What is tested | +|---------------|-----------------------------------------------------------------------| +| AutoContracts | NuGet `buildTransitive`, `AdditionalFiles`, and non-versioned naming | + ### Integration tests (`CaeriusNet.IntegrationTests`) | Category | What is tested | From 740ceafe229f3768bf8ad79095558e1d42424714 Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 19:49:51 +0200 Subject: [PATCH 5/8] feat(autocontracts): scan database contracts by default connection --- Documentations/diagnostics/CAERIUS206.md | 6 +- Documentations/docs/diagnostics/CAERIUS206.md | 6 +- .../docs/documentation/auto-contracts.md | 45 +++++++ .../Tests/AutoContractsCliTests.cs | 124 ++++++++++++++++-- .../AutoContractsPackagingTests.cs | 47 ++++++- .../CaeriusNet.SqlServer.Contracts.csproj | 2 + .../CommandLineOptions.cs | 36 ++++- .../ConnectionStringResolver.cs | 103 +++++++++++++++ .../CaeriusNet.SqlServer.Contracts/Program.cs | 13 +- .../CaeriusNet.SqlServer.Contracts/README.md | 30 +++++ .../SqlServerContractDiscoverer.cs | 86 ++++++------ .../CaeriusNet.SqlServer.Contracts.props | 7 +- .../CaeriusNet.SqlServer.Contracts.targets | 19 ++- 13 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs diff --git a/Documentations/diagnostics/CAERIUS206.md b/Documentations/diagnostics/CAERIUS206.md index 1415d1c..7205c97 100644 --- a/Documentations/diagnostics/CAERIUS206.md +++ b/Documentations/diagnostics/CAERIUS206.md @@ -11,8 +11,10 @@ not part of that generated surface. ## How to fix -Return values through the result set instead of `OUTPUT` parameters, or keep the procedure outside -AutoContracts read-only contract generation. +Return values through the first result set instead of `OUTPUT` parameters. AutoContracts scans the +application schemas in the selected database, so a procedure with `OUTPUT` parameters in that +database is considered an incompatible contract until it is changed or moved out of that database's +generated contract boundary. After changing the procedure contract, run `Pull` and review the refreshed `caerius.contracts.json`. diff --git a/Documentations/docs/diagnostics/CAERIUS206.md b/Documentations/docs/diagnostics/CAERIUS206.md index 1415d1c..7205c97 100644 --- a/Documentations/docs/diagnostics/CAERIUS206.md +++ b/Documentations/docs/diagnostics/CAERIUS206.md @@ -11,8 +11,10 @@ not part of that generated surface. ## How to fix -Return values through the result set instead of `OUTPUT` parameters, or keep the procedure outside -AutoContracts read-only contract generation. +Return values through the first result set instead of `OUTPUT` parameters. AutoContracts scans the +application schemas in the selected database, so a procedure with `OUTPUT` parameters in that +database is considered an incompatible contract until it is changed or moved out of that database's +generated contract boundary. After changing the procedure contract, run `Pull` and review the refreshed `caerius.contracts.json`. diff --git a/Documentations/docs/documentation/auto-contracts.md b/Documentations/docs/documentation/auto-contracts.md index de3d809..d2ccdc6 100644 --- a/Documentations/docs/documentation/auto-contracts.md +++ b/Documentations/docs/documentation/auto-contracts.md @@ -48,6 +48,51 @@ AutoContracts has two separate execution phases: | `Verify` | Reads SQL Server metadata and compares it with `caerius.contracts.json`. Use this in validation workflows to detect drift. | | `Off` | Skips AutoContracts work. Use this when contract checks are not needed for a run. | +## Connection string resolution + +Prefer resolving the discovery connection string by configuration name, so AutoContracts follows +the same development workflow as the application: + +```xml + + Pull + DefaultConnection + +``` + +The tool reads `ConnectionStrings:DefaultConnection` from .NET configuration. It loads +`appsettings.json`, optional `appsettings.{environment}.json`, optional user secrets, and +environment variables from the project directory. `CaeriusContractsConfigurationEnvironment` can +force the environment-specific file, and `CaeriusContractsUserSecretsId` can override the project +`UserSecretsId`. + +This also fits Aspire-driven projects: when the tool runs in an Aspire-provided environment, +`ConnectionStrings__DefaultConnection` is resolved by the same configuration path. For a local +manual database, store the value in `appsettings.Development.json` or user secrets. Do not commit +production secrets. + +The target database is the database named by the connection string, for example `Database=...` or +`Initial Catalog=...`. AutoContracts scans all application schemas in that database, including +`dbo`, and ignores SQL Server system schemas such as `sys`, `INFORMATION_SCHEMA`, and fixed-role +compatibility schemas. + +The lower-level escape hatches remain available: + +```powershell +dotnet run --project Tools/CaeriusNet.SqlServer.Contracts -- ` + pull ` + --connection-name DefaultConnection ` + --configuration-base-path .\src\MyApp ` + --output .\src\MyApp\caerius.contracts.json +``` + +```powershell +dotnet run --project Tools/CaeriusNet.SqlServer.Contracts -- ` + verify ` + --connection-env SQLSERVER_CONNECTION_STRING ` + --manifest .\src\MyApp\caerius.contracts.json +``` + ## Recommended workflow 1. Change the stored procedure contract intentionally. diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs index eccce88..4b7fa64 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsCliTests.cs @@ -32,13 +32,12 @@ public async Task Cli_Pull_And_Verify_Generates_Manifest_From_ReadOnly_Metadata( "pull", "--connection-string", fixture.ConnectionString, - "--schemas", - "contracts", "--output", manifestPath); - Assert.True(pull.ExitCode == 0, pull.ToString()); + Assert.True(pull.ExitCode is 0 or 1, pull.ToString()); Assert.True(File.Exists(manifestPath), $"Expected manifest to be written at {manifestPath}."); + Assert.Contains("CAERIUS206 error", pull.StandardError, StringComparison.Ordinal); await AssertManifestShapeAsync(manifestPath); @@ -51,12 +50,11 @@ public async Task Cli_Pull_And_Verify_Generates_Manifest_From_ReadOnly_Metadata( "verify", "--connection-string", fixture.ConnectionString, - "--schemas", - "contracts", "--manifest", manifestPath); - Assert.True(verify.ExitCode == 0, verify.ToString()); + Assert.True(verify.ExitCode is 0 or 1, verify.ToString()); + Assert.Contains("CAERIUS206 error", verify.StandardError, StringComparison.Ordinal); Assert.Equal(0, await ReadExecutionProbeAsync()); } finally @@ -67,6 +65,91 @@ public async Task Cli_Pull_And_Verify_Generates_Manifest_From_ReadOnly_Metadata( } } + [SqlServerAvailableFact] + public async Task Cli_Pull_Can_Resolve_Connection_Name_From_Appsettings() + { + var repoRoot = FindRepoRoot(); + var toolProject = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", + "CaeriusNet.SqlServer.Contracts.csproj"); + var tempDirectory = Path.Combine(Path.GetTempPath(), "caerius-contracts-" + Guid.NewGuid().ToString("N")); + var manifestPath = Path.Combine(tempDirectory, "caerius.contracts.json"); + var jsonConnectionString = JsonSerializer.Serialize(fixture.ConnectionString); + Directory.CreateDirectory(tempDirectory); + await File.WriteAllTextAsync( + Path.Combine(tempDirectory, "appsettings.json"), + $$""" + { + "ConnectionStrings": { + "AutoContracts": {{jsonConnectionString}} + } + } + """); + + try + { + var pull = await RunDotnetAsync( + repoRoot, + "run", + "--project", + toolProject, + "--", + "pull", + "--connection-name", + "AutoContracts", + "--configuration-base-path", + tempDirectory, + "--output", + manifestPath); + + Assert.True(pull.ExitCode is 0 or 1, pull.ToString()); + Assert.True(File.Exists(manifestPath), $"Expected manifest to be written at {manifestPath}."); + } + finally + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + } + } + + [SqlServerAvailableFact] + public async Task Cli_Pull_Can_Resolve_Connection_Name_From_Environment() + { + var repoRoot = FindRepoRoot(); + var toolProject = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", + "CaeriusNet.SqlServer.Contracts.csproj"); + var manifestPath = Path.Combine(Path.GetTempPath(), "caerius-contracts-" + Guid.NewGuid().ToString("N"), + "caerius.contracts.json"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + + try + { + var pull = await RunDotnetAsync( + repoRoot, + new Dictionary + { + ["ConnectionStrings__AutoContracts"] = fixture.ConnectionString + }, + "run", + "--project", + toolProject, + "--", + "pull", + "--connection-name", + "AutoContracts", + "--output", + manifestPath); + + Assert.True(pull.ExitCode is 0 or 1, pull.ToString()); + Assert.True(File.Exists(manifestPath), $"Expected manifest to be written at {manifestPath}."); + } + finally + { + var directory = Path.GetDirectoryName(manifestPath); + if (directory is not null && Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } + [SqlServerAvailableFact] public async Task Cli_Verify_Reports_Missing_Procedure_And_Drift() { @@ -101,12 +184,10 @@ await File.WriteAllTextAsync(manifestPath, """ "verify", "--connection-string", fixture.ConnectionString, - "--schemas", - "contracts", "--manifest", manifestPath); - Assert.Equal(2, verify.ExitCode); + Assert.NotEqual(0, verify.ExitCode); Assert.Contains("CAERIUS201 error", verify.StandardError, StringComparison.Ordinal); Assert.Contains("CAERIUS209 error", verify.StandardError, StringComparison.Ordinal); } @@ -126,12 +207,18 @@ private static async Task AssertManifestShapeAsync(string manifestPath) Assert.Equal(1, root.GetProperty("version").GetInt32()); - var tableType = Assert.Single(root.GetProperty("tableTypes").EnumerateArray()); + var tableTypes = root.GetProperty("tableTypes").EnumerateArray().ToArray(); + var tableType = Assert.Single(tableTypes, candidate => + candidate.GetProperty("schema").GetString() == "contracts" && + candidate.GetProperty("name").GetString() == "WidgetTvp"); Assert.Equal("contracts", tableType.GetProperty("schema").GetString()); Assert.Equal("WidgetTvp", tableType.GetProperty("name").GetString()); Assert.Equal("decimal(18,4)", tableType.GetProperty("columns")[3].GetProperty("sqlType").GetString()); Assert.Equal(18, tableType.GetProperty("columns")[3].GetProperty("precision").GetInt32()); Assert.Equal(4, tableType.GetProperty("columns")[3].GetProperty("scale").GetInt32()); + Assert.Contains(tableTypes, candidate => + candidate.GetProperty("schema").GetString() == "dbo" && + candidate.GetProperty("name").GetString() == "AutoContractsWidgetTvp"); var procedures = root.GetProperty("procedures").EnumerateArray().ToArray(); Assert.Contains(procedures, procedure => @@ -143,6 +230,11 @@ private static async Task AssertManifestShapeAsync(string manifestPath) Assert.Contains(procedures, procedure => procedure.GetProperty("schema").GetString() == "contracts" && procedure.GetProperty("name").GetString() == "usp_QuoteWidget"); + Assert.Contains(procedures, procedure => + procedure.GetProperty("schema").GetString() == "dbo" && + procedure.GetProperty("name").GetString() == "usp_AutoContracts_SearchWidgets"); + Assert.DoesNotContain(procedures, procedure => + procedure.GetProperty("schema").GetString() is "sys" or "INFORMATION_SCHEMA"); } private async Task SetExecutionProbeAsync(int value) @@ -166,6 +258,14 @@ private async Task ReadExecutionProbeAsync() } private static async Task RunDotnetAsync(string workingDirectory, params string[] arguments) + { + return await RunDotnetAsync(workingDirectory, null, arguments); + } + + private static async Task RunDotnetAsync( + string workingDirectory, + IReadOnlyDictionary? environmentVariables, + params string[] arguments) { var startInfo = new ProcessStartInfo("dotnet") { @@ -177,6 +277,10 @@ private static async Task RunDotnetAsync(string workingDirectory, foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + if (environmentVariables is not null) + foreach (var (key, value) in environmentVariables) + startInfo.Environment[key] = value; + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); var standardOutput = process.StandardOutput.ReadToEndAsync(); var standardError = process.StandardError.ReadToEndAsync(); diff --git a/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs index e648090..692841c 100644 --- a/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs +++ b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs @@ -180,6 +180,51 @@ await File.ReadAllLinesAsync(additionalFiles), line => string.Equals(line, $"{Path.GetFullPath(manifest)}|true", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task TargetsCanUseConnectionNameConfigurationArguments() + { + var repoRoot = FindRepoRoot(); + var props = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", "buildTransitive", + "CaeriusNet.SqlServer.Contracts.props"); + var targets = Path.Combine(repoRoot, "Tools", "CaeriusNet.SqlServer.Contracts", "buildTransitive", + "CaeriusNet.SqlServer.Contracts.targets"); + + using var temp = new TemporaryDirectory(); + var project = Path.Combine(temp.Path, "Smoke.proj"); + var argumentsOutput = Path.Combine(temp.Path, "connection-arguments.txt"); + + await File.WriteAllTextAsync( + project, + $$""" + + + ApplicationDb + Development + caerius-test-secrets + + + + + + + + + + """); + + var result = await RunDotnetAsync(temp.Path, "msbuild", project, "/t:RunSmoke", "/nologo", "/v:minimal"); + + Assert.True(result.ExitCode == 0, result.ToString()); + var line = Assert.Single(await File.ReadAllLinesAsync(argumentsOutput)); + Assert.Contains("--connection-name \"ApplicationDb\"", line, StringComparison.Ordinal); + Assert.Contains("--configuration-base-path", line, StringComparison.Ordinal); + Assert.Contains("--configuration-environment \"Development\"", line, StringComparison.Ordinal); + Assert.Contains("--user-secrets-id \"caerius-test-secrets\"", line, StringComparison.Ordinal); + Assert.DoesNotContain("--connection-env", line, StringComparison.Ordinal); + } + [Fact] public void AutoContractsMsbuildFilesDoNotUseVersionedNames() { @@ -300,4 +345,4 @@ public void Dispose() if (Directory.Exists(Path)) Directory.Delete(Path, true); } } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj index 2b3c83d..213814d 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj +++ b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj @@ -26,6 +26,8 @@ + + diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs b/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs index a896253..b075f9a 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/CommandLineOptions.cs @@ -3,7 +3,10 @@ namespace CaeriusNet.SqlServer.Contracts; internal sealed record CommandLineOptions( string? ConnectionEnv, string? ConnectionString, - string[] Schemas, + string? ConnectionName, + string? ConfigurationBasePath, + string? ConfigurationEnvironment, + string? UserSecretsId, string? Output, string? Manifest) { @@ -11,7 +14,10 @@ internal static CommandLineOptions Parse(string[] args) { string? connectionEnv = null; string? connectionString = null; - string[] schemas = []; + string? connectionName = null; + string? configurationBasePath = null; + string? configurationEnvironment = null; + string? userSecretsId = null; string? output = null; string? manifest = null; @@ -35,9 +41,17 @@ string Next() case "--connection-string": connectionString = Next(); break; - case "--schemas": - schemas = Next() - .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + case "--connection-name": + connectionName = Next(); + break; + case "--configuration-base-path": + configurationBasePath = Next(); + break; + case "--configuration-environment": + configurationEnvironment = Next(); + break; + case "--user-secrets-id": + userSecretsId = Next(); break; case "--output": output = Next(); @@ -50,6 +64,14 @@ string Next() } } - return new CommandLineOptions(connectionEnv, connectionString, schemas, output, manifest); + return new CommandLineOptions( + connectionEnv, + connectionString, + connectionName, + configurationBasePath, + configurationEnvironment, + userSecretsId, + output, + manifest); } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs b/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs new file mode 100644 index 0000000..6270039 --- /dev/null +++ b/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs @@ -0,0 +1,103 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace CaeriusNet.SqlServer.Contracts; + +internal static class ConnectionStringResolver +{ + private const string DefaultConnectionName = "DefaultConnection"; + + internal static string Resolve(CommandLineOptions options) + { + var connectionString = ResolveRawConnectionString(options); + var builder = new SqlConnectionStringBuilder(connectionString) + { + ApplicationIntent = ApplicationIntent.ReadOnly + }; + + return builder.ConnectionString; + } + + private static string ResolveRawConnectionString(CommandLineOptions options) + { + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + return options.ConnectionString; + + if (!string.IsNullOrWhiteSpace(options.ConnectionEnv)) + return ResolveFromEnvironment(options.ConnectionEnv); + + if (!string.IsNullOrWhiteSpace(options.ConnectionName)) + return ResolveFromConfiguration(options); + + return ResolveFromConfiguration(options with { ConnectionName = DefaultConnectionName }); + } + + private static string ResolveFromEnvironment(string name) + { + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException( + $"Environment variable '{name}' does not contain a SQL Server connection string."); + + return value; + } + + private static string ResolveFromConfiguration(CommandLineOptions options) + { + var connectionName = options.ConnectionName!; + var configuration = BuildConfiguration(options); + var value = configuration.GetConnectionString(connectionName); + + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException( + $"Connection string 'ConnectionStrings:{connectionName}' was not found in .NET configuration."); + + return value; + } + + private static IConfigurationRoot BuildConfiguration(CommandLineOptions options) + { + var basePath = string.IsNullOrWhiteSpace(options.ConfigurationBasePath) + ? Directory.GetCurrentDirectory() + : Path.GetFullPath(options.ConfigurationBasePath); + var environment = ResolveEnvironment(options.ConfigurationEnvironment); + var builder = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + + if (!string.IsNullOrWhiteSpace(environment)) + builder.AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false); + + if (!string.IsNullOrWhiteSpace(options.UserSecretsId)) + builder.AddJsonFile(GetUserSecretsPath(options.UserSecretsId), optional: true, reloadOnChange: false); + + builder.AddEnvironmentVariables(); + + return builder.Build(); + } + + private static string GetUserSecretsPath(string userSecretsId) + { + if (OperatingSystem.IsWindows()) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + return Path.Combine(appData, "Microsoft", "UserSecrets", userSecretsId, "secrets.json"); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".microsoft", "usersecrets", userSecretsId, "secrets.json"); + } + + private static string? ResolveEnvironment(string? explicitEnvironment) + { + if (!string.IsNullOrWhiteSpace(explicitEnvironment)) + return explicitEnvironment; + + var dotnetEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + if (!string.IsNullOrWhiteSpace(dotnetEnvironment)) + return dotnetEnvironment; + + return Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + } +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/Program.cs b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs index bb20c60..4a4dcd3 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/Program.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs @@ -149,14 +149,19 @@ private static void WriteUsage() CaeriusNet SQL Server contracts Commands: - pull --connection-env CAERIUS_SQLSERVER --schemas dbo --output {DefaultManifestPath} - verify --connection-env CAERIUS_SQLSERVER --schemas dbo --manifest {DefaultManifestPath} + pull --connection-name DefaultConnection --output {DefaultManifestPath} + verify --connection-name DefaultConnection --manifest {DefaultManifestPath} Options: --connection-env Environment variable containing the SQL Server connection string. - Defaults to CAERIUS_SQLSERVER. --connection-string SQL Server connection string. Overrides --connection-env. - --schemas Comma or semicolon separated schemas. Defaults to dbo. + --connection-name Reads ConnectionStrings: from .NET configuration. + Defaults to DefaultConnection. + --configuration-base-path + Directory containing appsettings.json. Defaults to the current directory. + --configuration-environment + Loads appsettings..json. Defaults to DOTNET_ENVIRONMENT or ASPNETCORE_ENVIRONMENT. + --user-secrets-id Optional user secrets id used with --connection-name. --output Output manifest path for pull. Defaults to {DefaultManifestPath}. --manifest Manifest path for verify. """); diff --git a/Tools/CaeriusNet.SqlServer.Contracts/README.md b/Tools/CaeriusNet.SqlServer.Contracts/README.md index 5e413d6..f7e263f 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/README.md +++ b/Tools/CaeriusNet.SqlServer.Contracts/README.md @@ -5,3 +5,33 @@ Build-time SQL Server contract discovery package for CaeriusNet AutoContracts. The package contributes MSBuild `buildTransitive` imports and an embedded CLI used to pull or verify `caerius.contracts.json` from SQL Server metadata. The source generator itself reads only `AdditionalFiles` and does not connect to SQL Server. + +## Connection strings + +The preferred project setup is to resolve the SQL Server connection by .NET configuration name: + +```xml + + Pull + DefaultConnection + +``` + +The embedded CLI reads `ConnectionStrings:DefaultConnection` from `appsettings.json`, +`appsettings.{environment}.json`, user secrets, and environment variables. This matches manual +configuration and Aspire-provided `ConnectionStrings__DefaultConnection` values. + +The database is selected by the SQL Server connection string. AutoContracts scans all application +schemas in that database, including `dbo`, and ignores SQL Server system schemas such as `sys` and +`INFORMATION_SCHEMA`. + +Direct CLI usage: + +```powershell +dotnet CaeriusNet.SqlServer.Contracts.dll pull ` + --connection-name DefaultConnection ` + --configuration-base-path . ` + --output caerius.contracts.json +``` + +`--connection-env` and `--connection-string` remain available for CI and explicit scripting. diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs index 86d0f3a..4ee095b 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerContractDiscoverer.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Data; using System.Globalization; using Microsoft.Data.SqlClient; @@ -11,17 +10,14 @@ internal static async Task DiscoverAsync( CommandLineOptions options, ContractDiagnosticSink diagnostics) { - var connectionString = ResolveConnectionString(options); - var schemas = options.Schemas.Length == 0 - ? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "dbo") - : options.Schemas.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); + var connectionString = ConnectionStringResolver.Resolve(options); await using var connection = new SqlConnection(connectionString); await connection.OpenAsync().ConfigureAwait(false); var database = await DiscoverDatabaseAsync(connection).ConfigureAwait(false); - var tableTypes = await DiscoverTableTypesAsync(connection, schemas, diagnostics).ConfigureAwait(false); - var procedures = await DiscoverProceduresAsync(connection, schemas, tableTypes, diagnostics) + var tableTypes = await DiscoverTableTypesAsync(connection, diagnostics).ConfigureAwait(false); + var procedures = await DiscoverProceduresAsync(connection, tableTypes, diagnostics) .ConfigureAwait(false); return new ContractManifest( @@ -32,32 +28,6 @@ internal static async Task DiscoverAsync( procedures); } - private static string ResolveConnectionString(CommandLineOptions options) - { - string connectionString; - if (!string.IsNullOrWhiteSpace(options.ConnectionString)) - { - connectionString = options.ConnectionString; - } - else - { - var envName = options.ConnectionEnv ?? "CAERIUS_SQLSERVER"; - var value = Environment.GetEnvironmentVariable(envName); - if (string.IsNullOrWhiteSpace(value)) - throw new InvalidOperationException( - $"Environment variable '{envName}' does not contain a SQL Server connection string."); - - connectionString = value; - } - - var builder = new SqlConnectionStringBuilder(connectionString) - { - ApplicationIntent = ApplicationIntent.ReadOnly - }; - - return builder.ConnectionString; - } - private static async Task DiscoverDatabaseAsync(SqlConnection connection) { await using var command = connection.CreateCommand(); @@ -82,13 +52,12 @@ FROM sys.databases private static async Task> DiscoverTableTypesAsync( SqlConnection connection, - ImmutableHashSet schemas, ContractDiagnosticSink diagnostics) { await using var command = connection.CreateCommand(); command.CommandText = """ SELECT - SCHEMA_NAME(tt.schema_id) AS schema_name, + s.name AS schema_name, tt.name AS table_type_name, tt.user_type_id, c.column_id, @@ -106,8 +75,24 @@ JOIN sys.types AS userTyp LEFT JOIN sys.types AS sysTyp ON sysTyp.system_type_id = c.system_type_id AND sysTyp.user_type_id = sysTyp.system_type_id + JOIN sys.schemas AS s + ON s.schema_id = tt.schema_id + WHERE s.name NOT IN ( + N'sys', + N'INFORMATION_SCHEMA', + N'guest', + N'db_owner', + N'db_accessadmin', + N'db_securityadmin', + N'db_ddladmin', + N'db_backupoperator', + N'db_datareader', + N'db_datawriter', + N'db_denydatareader', + N'db_denydatawriter' + ) ORDER BY - SCHEMA_NAME(tt.schema_id), + s.name, tt.name, c.column_id; """; @@ -117,9 +102,6 @@ ORDER BY while (await reader.ReadAsync().ConfigureAwait(false)) { var schema = reader.GetString(0); - if (!schemas.Contains(schema)) - continue; - var name = reader.GetString(1); var key = schema + "." + name; if (!tableTypes.TryGetValue(key, out var tableType)) @@ -169,11 +151,10 @@ ORDER BY private static async Task> DiscoverProceduresAsync( SqlConnection connection, - ImmutableHashSet schemas, IReadOnlyList tableTypes, ContractDiagnosticSink diagnostics) { - var procedures = await DiscoverProcedureHeadersAsync(connection, schemas).ConfigureAwait(false); + var procedures = await DiscoverProcedureHeadersAsync(connection).ConfigureAwait(false); var tableTypesBySqlName = tableTypes.ToDictionary( tableType => tableType.Schema + "." + tableType.Name, StringComparer.OrdinalIgnoreCase); @@ -209,8 +190,7 @@ private static async Task> DiscoverProceduresAs } private static async Task> DiscoverProcedureHeadersAsync( - SqlConnection connection, - ImmutableHashSet schemas) + SqlConnection connection) { await using var command = connection.CreateCommand(); command.CommandText = """ @@ -222,6 +202,20 @@ FROM sys.procedures AS p JOIN sys.schemas AS s ON s.schema_id = p.schema_id WHERE p.is_ms_shipped = 0 + AND s.name NOT IN ( + N'sys', + N'INFORMATION_SCHEMA', + N'guest', + N'db_owner', + N'db_accessadmin', + N'db_securityadmin', + N'db_ddladmin', + N'db_backupoperator', + N'db_datareader', + N'db_datawriter', + N'db_denydatareader', + N'db_denydatawriter' + ) ORDER BY s.name, p.name; @@ -230,11 +224,7 @@ ORDER BY var procedures = new List(); await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); while (await reader.ReadAsync().ConfigureAwait(false)) - { - var schema = reader.GetString(1); - if (schemas.Contains(schema)) - procedures.Add(new ProcedureHeader(reader.GetInt32(0), schema, reader.GetString(2))); - } + procedures.Add(new ProcedureHeader(reader.GetInt32(0), reader.GetString(1), reader.GetString(2))); return procedures; } diff --git a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props index 639a584..37f5fdc 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props +++ b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.props @@ -2,9 +2,12 @@ Off $([System.String]::Copy('$(CaeriusContractsMode)').Trim().ToUpperInvariant()) - dbo $(ProjectDir)caerius.contracts.json - CAERIUS_SQLSERVER + + DefaultConnection + $(MSBuildProjectDirectory) + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\tools\net10.0\any\CaeriusNet.SqlServer.Contracts.dll')) dotnet "$(CaeriusContractsToolPath)" diff --git a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets index f25e0a2..db83bb1 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets +++ b/Tools/CaeriusNet.SqlServer.Contracts/buildTransitive/CaeriusNet.SqlServer.Contracts.targets @@ -1,4 +1,17 @@ + + $(CaeriusContractsUserSecretsId) + $(UserSecretsId) + true + + --connection-string "$(CaeriusContractsConnectionString)" + --connection-env "$(CaeriusContractsConnectionStringEnv)" + --connection-name "$(CaeriusContractsConnectionName)" --configuration-base-path "$(CaeriusContractsConfigurationBasePath)" + $(CaeriusContractsConnectionArguments) --configuration-environment "$(CaeriusContractsConfigurationEnvironment)" + $(CaeriusContractsConnectionArguments) --user-secrets-id "$(CaeriusContractsEffectiveUserSecretsId)" + --connection-name "DefaultConnection" --configuration-base-path "$(CaeriusContractsConfigurationBasePath)" + + @@ -12,7 +25,8 @@ Condition="'$(DesignTimeBuild)' != 'true' and '$(CaeriusContractsModeNormalized)' == 'PULL'"> + WorkingDirectory="$(CaeriusContractsConfigurationBasePath)" + Command="$(CaeriusContractsToolCommand) pull $(CaeriusContractsConnectionArguments) --output "$(CaeriusContractsOutput)""/> + WorkingDirectory="$(CaeriusContractsConfigurationBasePath)" + Command="$(CaeriusContractsToolCommand) verify $(CaeriusContractsConnectionArguments) --manifest "$(CaeriusContractsOutput)""/> Date: Thu, 14 May 2026 19:53:27 +0200 Subject: [PATCH 6/8] fix: add missing newlines at end of multiple files --- .../AutoContractsManifestModels.cs | 2 +- .../AutoContractsManifestParser.cs | 2 +- .../AutoContracts/AutoContractsSqlTypes.cs | 2 +- Analyzer/Diagnostics/DiagnosticDescriptors.cs | 2 +- Analyzer/GeneratorUsageAnalyzer.cs | 2 +- Analyzer/GlobalUsings.cs | 2 +- Analyzer/Helpers/IsExternalInit.cs | 2 +- Analyzer/Helpers/SqlTypeDetector.cs | 2 +- Analyzer/Helpers/TypeStructureValidator.cs | 2 +- Benchmark/Data/Generated/BenchmarkItemDto.cs | 2 +- Benchmark/Data/Generated/BenchmarkTvpItem.cs | 2 +- .../Data/Generated/BenchmarkTvpItem10Col.cs | 2 +- .../Data/Generated/BenchmarkTvpItem5Col.cs | 2 +- Benchmark/Data/Generated/NullableRowDto.cs | 2 +- Benchmark/Data/Generated/WideRowDto.cs | 2 +- Benchmark/Data/Simple/SimpleDto.cs | 2 +- Benchmark/Program.cs | 1 + Benchmark/Usings.cs | 2 +- Benchmark/Workshops/BenchmarkConfig.cs | 2 +- .../AutoContractsPlanningBenchmarks.cs | 2 +- .../Workshops/Benchs/Cache/FrozenCacheBench.cs | 2 +- .../Benchs/Cache/InMemoryCacheBench.cs | 2 +- .../CreateEnumerableToBench.cs | 2 +- .../CreateImmutableArrayToBench.cs | 2 +- .../CreateCollections/CreateListToBench.cs | 2 +- .../CreateReadOnlyCollectionToBench.cs | 2 +- .../ListCapacity/ListWithCapacityToBench.cs | 2 +- .../ListWithCapacityWithOverextendToBench.cs | 2 +- .../ListWithLessCapacityThanNeededToBench.cs | 2 +- .../ListCapacity/ListWithoutCapacityToBench.cs | 2 +- .../Benchs/Mapping/DtoMappingBench.cs | 2 +- .../Mapping/NullableColumnMappingBench.cs | 2 +- .../Benchs/Mapping/WideRowDtoMappingBench.cs | 2 +- .../Benchs/Parameters/AddTvpParameterBench.cs | 2 +- .../Parameters/SpParameterBuilderBench.cs | 2 +- .../ReadCollections/ReadEnumerableToBench.cs | 2 +- .../ReadImmutableArrayToBench.cs | 2 +- .../Benchs/ReadCollections/ReadListToBench.cs | 2 +- .../ReadReadOnlyCollectionToBench.cs | 2 +- .../Benchs/SqlServer/BatchedVsSingleBench.cs | 2 +- .../Benchs/SqlServer/ConnectionPoolBench.cs | 2 +- .../Benchs/SqlServer/MultiResultSetBench.cs | 2 +- .../Benchs/SqlServer/SpExecutionBench.cs | 2 +- .../Benchs/SqlServer/SpOutputParameterBench.cs | 2 +- .../SqlServer/SqlBenchmarkGlobalSetup.cs | 2 +- .../Benchs/SqlServer/TvpFullRoundtripBench.cs | 2 +- .../Benchs/Tvp/TvpColumnScalingBench.cs | 2 +- .../Benchs/Tvp/TvpSerializationBench.cs | 2 +- .../Benchs/Tvp/TvpVsDataTableBench.cs | 2 +- .../AppHost.cs | 2 +- .../GlobalUsings.cs | 2 +- .../DemoSections/MultiResultSetDemo.cs | 2 +- .../DemoSections/ReadDemo.cs | 2 +- .../DemoSections/TransactionDemo.cs | 2 +- .../DemoSections/TvpReadDemo.cs | 2 +- .../DemoWorker.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Program.cs | 2 +- .../Extensions.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Program.cs | 2 +- .../Abstractions/DashboardSnapshot.cs | 2 +- .../Abstractions/IUsersRepository.cs | 2 +- .../Abstractions/IUsersService.cs | 2 +- .../Bootstrap/ExampleDatabaseBootstrapper.cs | 2 +- .../Extensions/ServiceCollectionExtension.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Models/Dtos/OrderDto.cs | 2 +- .../Models/Dtos/UserDto.cs | 2 +- .../Models/Dtos/UserStatsDto.cs | 2 +- .../Models/Tvps/UsersGuidTvp.cs | 2 +- .../Models/Tvps/UsersIntGuidTvp.cs | 2 +- .../Models/Tvps/UsersIntTvp.cs | 2 +- .../UsersRepository.MultiResultSets.cs | 2 +- .../Repositories/UsersRepository.Reads.cs | 2 +- .../UsersRepository.Transactions.cs | 2 +- .../Repositories/UsersRepository.Tvp.cs | 2 +- .../Repositories/UsersRepository.cs | 2 +- .../Services/UsersService.cs | 2 +- .../AutoContractsCacheKeyEmitter.cs | 2 +- .../AutoContracts/AutoContractsClrTypes.cs | 2 +- .../AutoContractsGeneratedSource.cs | 2 +- .../AutoContractsManifestParser.cs | 2 +- .../AutoContracts/AutoContractsModels.cs | 2 +- .../AutoContracts/AutoContractsSourceNames.cs | 2 +- .../AutoContracts/AutoContractsSqlEmitter.cs | 2 +- .../AutoContracts/AutoContractsSqlFacets.cs | 2 +- SourceGenerators/Dto/DtoEmitter.cs | 2 +- SourceGenerators/Dto/DtoExtractor.cs | 2 +- SourceGenerators/Dto/DtoSourceGenerator.cs | 2 +- SourceGenerators/GlobalUsings.cs | 2 +- SourceGenerators/Helpers/ColumnExtractor.cs | 2 +- SourceGenerators/Helpers/GeneratedCodeInfo.cs | 2 +- SourceGenerators/Helpers/HintNameBuilder.cs | 2 +- SourceGenerators/Helpers/IsExternalInit.cs | 2 +- SourceGenerators/Helpers/NamespaceHelper.cs | 2 +- .../Helpers/SqlMetaDataExpressionBuilder.cs | 2 +- SourceGenerators/Helpers/TypeDetector.cs | 2 +- .../Helpers/TypeStructureValidator.cs | 2 +- SourceGenerators/Models/ColumnKind.cs | 2 +- SourceGenerators/Models/ColumnModel.cs | 2 +- SourceGenerators/Models/DtoModel.cs | 2 +- SourceGenerators/Models/EquatableArray.cs | 2 +- SourceGenerators/Models/TvpModel.cs | 2 +- SourceGenerators/Tvp/TvpEmitter.cs | 2 +- SourceGenerators/Tvp/TvpExtractor.cs | 2 +- SourceGenerators/Tvp/TvpSourceGenerator.cs | 2 +- Src/Abstractions/ICaeriusNetCache.cs | 2 +- Src/Abstractions/ICaeriusNetDbContext.cs | 2 +- Src/Abstractions/ICaeriusNetTransaction.cs | 2 +- .../AutoContractGenerateDtoAttribute.cs | 2 +- .../AutoContractGenerateProcedureAttribute.cs | 2 +- .../AutoContractGenerateTvpAttribute.cs | 2 +- Src/Attributes/Dto/GenerateDtoAttribute.cs | 2 +- Src/Attributes/Tvp/GenerateTvpAttribute.cs | 2 +- Src/Builders/CaeriusNetBuilder.cs | 2 +- .../StoredProcedureParametersBuilder.Typed.cs | 1 - .../StoredProcedureParametersBuilder.cs | 2 +- Src/Caches/CaeriusNetCache.cs | 2 +- Src/Caches/FrozenCacheManager.cs | 2 +- Src/Caches/InMemoryCacheManager.cs | 2 +- Src/Caches/RedisCacheManager.cs | 12 ++++++++---- .../MultiIEnumerableReadSqlAsyncCommands.cs | 2 +- .../MultiImmutableArrayReadSqlAsyncCommands.cs | 2 +- ...tiReadOnlyCollectionReadSqlAsyncCommands.cs | 2 +- .../Reads/SimpleReadSqlAsyncCommands.cs | 2 +- .../BeginTransactionAsyncCommands.cs | 2 +- .../TransactionReadSqlAsyncCommands.cs | 2 +- .../TransactionWriteSqlAsyncCommands.cs | 2 +- Src/Commands/Writes/WriteSqlAsyncCommands.cs | 2 +- Src/Exceptions/CaeriusNetSqlException.cs | 2 +- Src/Factories/CaeriusNetDbContext.cs | 2 +- Src/Factories/CaeriusNetTransaction.cs | 2 +- Src/Helpers/CacheHelper.cs | 2 +- Src/Helpers/CacheType.cs | 2 +- Src/Helpers/EmptyCollections.cs | 2 +- Src/Helpers/MultiResultSetHelper.cs | 2 +- Src/Helpers/SqlCommandHelper.cs | 2 +- Src/Helpers/SqlCommandHelperTx.cs | 2 +- Src/Logging/LogMessages.cs | 2 +- Src/Logging/LoggerProvider.cs | 2 +- Src/Mappers/ICaeriusGeneratedProcedure.cs | 2 +- Src/Mappers/ISpMapper.cs | 2 +- Src/Mappers/ITvpMapper.cs | 2 +- Src/Mappers/StoredProcedureParameters.Typed.cs | 2 +- Src/Mappers/StoredProcedureParameters.cs | 2 +- Src/Telemetry/CaeriusActivityExtensions.cs | 2 +- Src/Telemetry/CaeriusDiagnostics.cs | 2 +- Src/Telemetry/CaeriusTelemetryOptions.cs | 2 +- .../GeneratorUsageAnalyzerTests.cs | 2 +- .../CaeriusNet.Analyzer.Tests/GlobalUsings.cs | 2 +- .../Utilities/AnalyzerTestHelper.cs | 2 +- .../Caching/IncrementalCachingTests.cs | 2 +- .../Diagnostics/DiagnosticTests.cs | 2 +- .../Dto/DtoSourceGeneratorTests.cs | 2 +- .../CaeriusNet.Generator.Tests/GlobalUsings.cs | 2 +- .../Helpers/NamespaceHelperTests.cs | 2 +- .../SqlMetaDataExpressionBuilderTests.cs | 2 +- .../Helpers/TypeDetectorTests.cs | 2 +- .../Tvp/TvpSourceGeneratorTests.cs | 2 +- .../Utilities/SourceGeneratorTestHelper.cs | 2 +- .../Fixtures/SqlServerFixture.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Models/WidgetDto.cs | 2 +- .../Tests/AutoContractsMetadataTests.cs | 2 +- .../Tests/CacheIntegrationTests.cs | 2 +- .../Tests/ExceptionHandlingTests.cs | 2 +- .../Tests/MultiResultSetTests.cs | 2 +- .../Tests/ReadCommandsTests.cs | 2 +- .../Tests/StoredProcedureTests.cs | 2 +- .../Tests/TransactionTests.cs | 2 +- .../Tests/TvpRoundtripTests.cs | 2 +- .../Builders/CaeriusNetBuilderTests.cs | 2 +- ...ProcedureParametersBuilderTelemetryTests.cs | 2 +- .../StoredProcedureParametersBuilderTests.cs | 2 +- ...oredProcedureParametersBuilderTypedTests.cs | 2 +- .../Caches/CaeriusNetCacheInvalidationTests.cs | 2 +- .../Caches/FrozenCacheManagerTests.cs | 2 +- .../Caches/FrozenCacheStateCollection.cs | 2 +- .../Caches/InMemoryCacheManagerTests.cs | 2 +- .../Caches/RedisCacheManagerTests.cs | 4 ++-- .../Exceptions/CaeriusNetSqlExceptionTests.cs | 2 +- .../Factories/CaeriusNetDbContextTests.cs | 2 +- Tests/CaeriusNet.Tests/GlobalUsings.cs | 2 +- .../Helpers/CacheHelperTests.cs | 2 +- .../Helpers/EmptyCollectionsTests.cs | 2 +- .../Helpers/SqlExceptionFactory.cs | 2 +- Tests/CaeriusNet.Tests/Helpers/TestTvpItem.cs | 2 +- .../Logging/LogMessagesSmokeTests.cs | 2 +- .../Logging/LoggerProviderTests.cs | 2 +- .../Mappers/StoredProcedureParametersTests.cs | 2 +- .../CaeriusActivityExtensionsTests.cs | 2 +- .../CaeriusDiagnosticsMetricsTests.cs | 2 +- .../Telemetry/CaeriusTelemetryOptionsTests.cs | 2 +- .../Telemetry/TelemetryTestsCollection.cs | 2 +- .../Transactions/CaeriusNetTransactionTests.cs | 2 +- Tests/README.md | 18 +++++++++--------- .../CSharpName.cs | 2 +- .../ConnectionStringResolver.cs | 6 +++--- .../ContractDiagnostics.cs | 2 +- .../ContractHasher.cs | 2 +- .../Contracts.cs | 2 +- .../CaeriusNet.SqlServer.Contracts/Program.cs | 2 -- .../SqlServerTypeMapper.cs | 2 +- .../SqlServerTypeName.cs | 2 +- 206 files changed, 222 insertions(+), 220 deletions(-) diff --git a/Analyzer/AutoContracts/AutoContractsManifestModels.cs b/Analyzer/AutoContracts/AutoContractsManifestModels.cs index c9b6423..9a1441f 100644 --- a/Analyzer/AutoContracts/AutoContractsManifestModels.cs +++ b/Analyzer/AutoContracts/AutoContractsManifestModels.cs @@ -69,4 +69,4 @@ public static implicit operator ImmutableArray(EquatableArray array) { return array.Items; } -} \ No newline at end of file +} diff --git a/Analyzer/AutoContracts/AutoContractsManifestParser.cs b/Analyzer/AutoContracts/AutoContractsManifestParser.cs index b8bcbc1..83ebd06 100644 --- a/Analyzer/AutoContracts/AutoContractsManifestParser.cs +++ b/Analyzer/AutoContracts/AutoContractsManifestParser.cs @@ -391,4 +391,4 @@ internal bool TryGetObject(string name, out JsonObject obj) return false; } } -} \ No newline at end of file +} diff --git a/Analyzer/AutoContracts/AutoContractsSqlTypes.cs b/Analyzer/AutoContracts/AutoContractsSqlTypes.cs index 963d910..7dc2989 100644 --- a/Analyzer/AutoContracts/AutoContractsSqlTypes.cs +++ b/Analyzer/AutoContracts/AutoContractsSqlTypes.cs @@ -28,4 +28,4 @@ internal static string Normalize(string sqlType) return span.ToString().ToLowerInvariant(); } -} \ No newline at end of file +} diff --git a/Analyzer/Diagnostics/DiagnosticDescriptors.cs b/Analyzer/Diagnostics/DiagnosticDescriptors.cs index a9c4e31..a6e1205 100644 --- a/Analyzer/Diagnostics/DiagnosticDescriptors.cs +++ b/Analyzer/Diagnostics/DiagnosticDescriptors.cs @@ -177,4 +177,4 @@ private static DiagnosticDescriptor CreateCompilationEnd( helpLinkUri, WellKnownDiagnosticTags.CompilationEnd); } -} \ No newline at end of file +} diff --git a/Analyzer/GeneratorUsageAnalyzer.cs b/Analyzer/GeneratorUsageAnalyzer.cs index a552ae4..76b08cb 100644 --- a/Analyzer/GeneratorUsageAnalyzer.cs +++ b/Analyzer/GeneratorUsageAnalyzer.cs @@ -247,4 +247,4 @@ private static bool TryGetNamedArgumentSyntax( argumentSyntax = null!; return false; } -} \ No newline at end of file +} diff --git a/Analyzer/GlobalUsings.cs b/Analyzer/GlobalUsings.cs index 34f5f3e..cc793df 100644 --- a/Analyzer/GlobalUsings.cs +++ b/Analyzer/GlobalUsings.cs @@ -7,4 +7,4 @@ global using Microsoft.CodeAnalysis; global using Microsoft.CodeAnalysis.CSharp; global using Microsoft.CodeAnalysis.CSharp.Syntax; -global using Microsoft.CodeAnalysis.Diagnostics; \ No newline at end of file +global using Microsoft.CodeAnalysis.Diagnostics; diff --git a/Analyzer/Helpers/IsExternalInit.cs b/Analyzer/Helpers/IsExternalInit.cs index 15d1327..64bc7c1 100644 --- a/Analyzer/Helpers/IsExternalInit.cs +++ b/Analyzer/Helpers/IsExternalInit.cs @@ -1,3 +1,3 @@ namespace System.Runtime.CompilerServices; -internal static class IsExternalInit; \ No newline at end of file +internal static class IsExternalInit; diff --git a/Analyzer/Helpers/SqlTypeDetector.cs b/Analyzer/Helpers/SqlTypeDetector.cs index e62a365..99577be 100644 --- a/Analyzer/Helpers/SqlTypeDetector.cs +++ b/Analyzer/Helpers/SqlTypeDetector.cs @@ -48,4 +48,4 @@ internal static string GetSqlType(ITypeSymbol type) } }; } -} \ No newline at end of file +} diff --git a/Analyzer/Helpers/TypeStructureValidator.cs b/Analyzer/Helpers/TypeStructureValidator.cs index fb05dfd..1a01b43 100644 --- a/Analyzer/Helpers/TypeStructureValidator.cs +++ b/Analyzer/Helpers/TypeStructureValidator.cs @@ -84,4 +84,4 @@ internal ValidationResult( internal TypeDeclarationSyntax? PrimaryConstructorDeclaration { get; } } -} \ No newline at end of file +} diff --git a/Benchmark/Data/Generated/BenchmarkItemDto.cs b/Benchmark/Data/Generated/BenchmarkItemDto.cs index 0d15583..20d2c4e 100644 --- a/Benchmark/Data/Generated/BenchmarkItemDto.cs +++ b/Benchmark/Data/Generated/BenchmarkItemDto.cs @@ -5,4 +5,4 @@ /// of ISpMapper<BenchmarkItemDto>.MapFromDataReader(). /// [GenerateDto] -public sealed partial record BenchmarkItemDto(int Id, Guid TraceId, string Name, decimal Price, bool IsActive); \ No newline at end of file +public sealed partial record BenchmarkItemDto(int Id, Guid TraceId, string Name, decimal Price, bool IsActive); diff --git a/Benchmark/Data/Generated/BenchmarkTvpItem.cs b/Benchmark/Data/Generated/BenchmarkTvpItem.cs index 55ab465..7bfca6b 100644 --- a/Benchmark/Data/Generated/BenchmarkTvpItem.cs +++ b/Benchmark/Data/Generated/BenchmarkTvpItem.cs @@ -5,4 +5,4 @@ /// which produces SqlDataRecord[] for streaming to SQL Server. /// [GenerateTvp(TvpName = "tvp_BenchmarkItem", Schema = "dbo")] -public sealed partial record BenchmarkTvpItem(int Id, string Name, decimal Price); \ No newline at end of file +public sealed partial record BenchmarkTvpItem(int Id, string Name, decimal Price); diff --git a/Benchmark/Data/Generated/BenchmarkTvpItem10Col.cs b/Benchmark/Data/Generated/BenchmarkTvpItem10Col.cs index 42bc9f1..8274f85 100644 --- a/Benchmark/Data/Generated/BenchmarkTvpItem10Col.cs +++ b/Benchmark/Data/Generated/BenchmarkTvpItem10Col.cs @@ -16,4 +16,4 @@ public sealed partial record BenchmarkTvpItem10Col( int Quantity, decimal Score, string Description, - Guid TraceId); \ No newline at end of file + Guid TraceId); diff --git a/Benchmark/Data/Generated/BenchmarkTvpItem5Col.cs b/Benchmark/Data/Generated/BenchmarkTvpItem5Col.cs index 7828390..9af7ddf 100644 --- a/Benchmark/Data/Generated/BenchmarkTvpItem5Col.cs +++ b/Benchmark/Data/Generated/BenchmarkTvpItem5Col.cs @@ -10,4 +10,4 @@ public sealed partial record BenchmarkTvpItem5Col( string Name, decimal Price, bool IsActive, - DateTime CreatedDate); \ No newline at end of file + DateTime CreatedDate); diff --git a/Benchmark/Data/Generated/NullableRowDto.cs b/Benchmark/Data/Generated/NullableRowDto.cs index db5aa9e..35abc8a 100644 --- a/Benchmark/Data/Generated/NullableRowDto.cs +++ b/Benchmark/Data/Generated/NullableRowDto.cs @@ -11,4 +11,4 @@ public sealed partial record NullableRowDto( string? Name, decimal? Price, bool? IsActive, - DateTime? CreatedAt); \ No newline at end of file + DateTime? CreatedAt); diff --git a/Benchmark/Data/Generated/WideRowDto.cs b/Benchmark/Data/Generated/WideRowDto.cs index 798fa7a..db4ddf7 100644 --- a/Benchmark/Data/Generated/WideRowDto.cs +++ b/Benchmark/Data/Generated/WideRowDto.cs @@ -16,4 +16,4 @@ public sealed partial record WideRowDto( int Quantity, decimal PriceWithTax, decimal DiscountedPrice, - bool InStock); \ No newline at end of file + bool InStock); diff --git a/Benchmark/Data/Simple/SimpleDto.cs b/Benchmark/Data/Simple/SimpleDto.cs index 43f3e39..ab7d9c6 100644 --- a/Benchmark/Data/Simple/SimpleDto.cs +++ b/Benchmark/Data/Simple/SimpleDto.cs @@ -17,4 +17,4 @@ public SimpleDto() public int Id { get; set; } public Guid Guid { get; set; } public string Name { get; set; } = null!; -} \ No newline at end of file +} diff --git a/Benchmark/Program.cs b/Benchmark/Program.cs index 9bfc873..5bb0aa0 100644 --- a/Benchmark/Program.cs +++ b/Benchmark/Program.cs @@ -1,4 +1,5 @@ using CaeriusNet.Benchmark; + // In CI: runs all benchmarks (category controlled by CI workflow args) // Locally: same — use RunningBenchmarks.Run_InMemory_Benchmarks() etc. directly var category = args.Length > 0 ? args[0].ToLowerInvariant() : "all"; diff --git a/Benchmark/Usings.cs b/Benchmark/Usings.cs index a0a9638..4b61a5e 100644 --- a/Benchmark/Usings.cs +++ b/Benchmark/Usings.cs @@ -18,4 +18,4 @@ global using CaeriusNet.Attributes.Dto; global using CaeriusNet.Attributes.Tvp; global using Microsoft.Data.SqlClient; -global using Microsoft.Data.SqlClient.Server; \ No newline at end of file +global using Microsoft.Data.SqlClient.Server; diff --git a/Benchmark/Workshops/BenchmarkConfig.cs b/Benchmark/Workshops/BenchmarkConfig.cs index 34d78e7..cb63161 100644 --- a/Benchmark/Workshops/BenchmarkConfig.cs +++ b/Benchmark/Workshops/BenchmarkConfig.cs @@ -44,4 +44,4 @@ public BenchmarkConfig() AddDiagnoser(MemoryDiagnoser.Default); WithOptions(ConfigOptions.DisableOptimizationsValidator); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs b/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs index ce7f2c3..4ef2b7d 100644 --- a/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs +++ b/Benchmark/Workshops/Benchs/AutoContracts/AutoContractsPlanningBenchmarks.cs @@ -275,4 +275,4 @@ private static void AppendString(IncrementalHash hash, string? value) AppendInt32(hash, bytes.Length); hash.AppendData(bytes); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs b/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs index f8ab841..538c278 100644 --- a/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs +++ b/Benchmark/Workshops/Benchs/Cache/FrozenCacheBench.cs @@ -128,4 +128,4 @@ public FrozenDictionary Write_FullRebuild() { return _sourceEntries.ToFrozenDictionary(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs b/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs index d66c6ee..b5328f3 100644 --- a/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs +++ b/Benchmark/Workshops/Benchs/Cache/InMemoryCacheBench.cs @@ -158,4 +158,4 @@ public int ReadWrite_GetOrCreate_WarmCache() return hits; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs index fc9befe..4032e2d 100644 --- a/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs +++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateEnumerableToBench.cs @@ -69,4 +69,4 @@ public IEnumerable Create_Array_AsEnumerable() { return _source.AsEnumerable(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs index ca36ab2..cb934ee 100644 --- a/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs +++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateImmutableArrayToBench.cs @@ -71,4 +71,4 @@ public ImmutableArray Create_FromSpan() { return ImmutableArray.Create(_source.AsSpan()); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs index 5a800c4..da8db5a 100644 --- a/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs +++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateListToBench.cs @@ -65,4 +65,4 @@ public List Create_LinqToList() { return _source.ToList(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs b/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs index e5fde24..e8cc417 100644 --- a/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs +++ b/Benchmark/Workshops/Benchs/CreateCollections/CreateReadOnlyCollectionToBench.cs @@ -54,4 +54,4 @@ public ReadOnlyCollection Create_LinqToList_AsReadOnly() { return _source.ToList().AsReadOnly(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs index 5ea57d4..c3849b4 100644 --- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs +++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityToBench.cs @@ -48,4 +48,4 @@ public List Create_ExactCapacity() list.AddRange(_source); return list; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs index bfc0935..56e32f4 100644 --- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs +++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithCapacityWithOverextendToBench.cs @@ -50,4 +50,4 @@ public List Create_OverextendCapacity() list.AddRange(_source); return list; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs index 1b7d287..3505eb7 100644 --- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs +++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithLessCapacityThanNeededToBench.cs @@ -49,4 +49,4 @@ public List Create_UnderCapacity() list.AddRange(_source); return list; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs b/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs index 25a85e4..6b5f9c7 100644 --- a/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs +++ b/Benchmark/Workshops/Benchs/ListCapacity/ListWithoutCapacityToBench.cs @@ -48,4 +48,4 @@ public List Create_NoCapacity() list.AddRange(_source); return list; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs index 02fab65..35cc9c9 100644 --- a/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs +++ b/Benchmark/Workshops/Benchs/Mapping/DtoMappingBench.cs @@ -78,4 +78,4 @@ public BenchmarkItemDto[] Map_Via_PreAllocatedArray() result[i] = new BenchmarkItemDto(_ids[i], _traceIds[i], _names[i], _prices[i], _isActives[i]); return result; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs index 3eeb5bf..7e8f6d0 100644 --- a/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs +++ b/Benchmark/Workshops/Benchs/Mapping/NullableColumnMappingBench.cs @@ -124,4 +124,4 @@ public NullableRowDto[] Map_Nullable_DTO_Upfront_Check() return result; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs b/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs index 12f56ab..e85aa3a 100644 --- a/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs +++ b/Benchmark/Workshops/Benchs/Mapping/WideRowDtoMappingBench.cs @@ -127,4 +127,4 @@ public WideRowDto[] Map_10Column_DTO_ToArray() _categories[i], _quantities[i], _pricesWithTax[i], _discountedPrices[i], _inStocks[i]); return result; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Parameters/AddTvpParameterBench.cs b/Benchmark/Workshops/Benchs/Parameters/AddTvpParameterBench.cs index 4f95926..2d46c69 100644 --- a/Benchmark/Workshops/Benchs/Parameters/AddTvpParameterBench.cs +++ b/Benchmark/Workshops/Benchs/Parameters/AddTvpParameterBench.cs @@ -83,4 +83,4 @@ public StoredProcedureParameters AddTvpParameter_TwoTvps() .AddTvpParameter("@ExistingItems", _itemList) .Build(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Parameters/SpParameterBuilderBench.cs b/Benchmark/Workshops/Benchs/Parameters/SpParameterBuilderBench.cs index da355df..43f7b70 100644 --- a/Benchmark/Workshops/Benchs/Parameters/SpParameterBuilderBench.cs +++ b/Benchmark/Workshops/Benchs/Parameters/SpParameterBuilderBench.cs @@ -41,4 +41,4 @@ public StoredProcedureParameters Build_WithMixedParameters() builder.AddParameter($"@Bit{i}", i % 2 == 0, SqlDbType.Bit); return builder.Build(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs index 5a6595c..8360f93 100644 --- a/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs +++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadEnumerableToBench.cs @@ -54,4 +54,4 @@ public int Read_LinqSum() { return _data.Sum(item => item.Id); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs index 36c7f3e..d5c8565 100644 --- a/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs +++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadImmutableArrayToBench.cs @@ -54,4 +54,4 @@ public int Read_LinqSum() { return _data.Sum(item => item.Id); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs index 5a5ab30..ef245bb 100644 --- a/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs +++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadListToBench.cs @@ -67,4 +67,4 @@ public int Read_LinqSum() { return _data.Sum(item => item.Id); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs b/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs index 4e56f11..3505bee 100644 --- a/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs +++ b/Benchmark/Workshops/Benchs/ReadCollections/ReadReadOnlyCollectionToBench.cs @@ -53,4 +53,4 @@ public int Read_LinqSum() { return _data.Sum(item => item.Id); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs b/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs index 29f618f..9daf033 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/BatchedVsSingleBench.cs @@ -70,4 +70,4 @@ public async Task Insert_Batched_Via_TVP_StoredProcedureCall() cmd.Parameters.Add(tvpParam); await cmd.ExecuteNonQueryAsync(); } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/ConnectionPoolBench.cs b/Benchmark/Workshops/Benchs/SqlServer/ConnectionPoolBench.cs index d9918f0..6cc8f56 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/ConnectionPoolBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/ConnectionPoolBench.cs @@ -134,4 +134,4 @@ public async Task ExecuteSP_ColdStartConnection() count++; return count; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/MultiResultSetBench.cs b/Benchmark/Workshops/Benchs/SqlServer/MultiResultSetBench.cs index 4bae6d9..60c727e 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/MultiResultSetBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/MultiResultSetBench.cs @@ -62,4 +62,4 @@ public async Task One_MultiResult_Query() return total; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs b/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs index c1cce0c..45e047e 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/SpExecutionBench.cs @@ -39,4 +39,4 @@ public async Task Execute_StoredProcedure_And_Materialise() return count; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/SpOutputParameterBench.cs b/Benchmark/Workshops/Benchs/SqlServer/SpOutputParameterBench.cs index 562b563..bb63846 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/SpOutputParameterBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/SpOutputParameterBench.cs @@ -114,4 +114,4 @@ public async Task Legacy_ScopeIdentity_TwoRoundtrips() var result = await idCmd.ExecuteScalarAsync(); return result is DBNull or null ? -1 : (int)result; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs b/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs index 88d9c9b..1662617 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/SqlBenchmarkGlobalSetup.cs @@ -175,4 +175,4 @@ public static async Task InitialiseAsync() await cmd.ExecuteNonQueryAsync(); } } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs b/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs index a17baa2..610fc81 100644 --- a/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs +++ b/Benchmark/Workshops/Benchs/SqlServer/TvpFullRoundtripBench.cs @@ -110,4 +110,4 @@ public async Task Manual_TvpFullRoundtrip() return count; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs index de23864..82ba176 100644 --- a/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs +++ b/Benchmark/Workshops/Benchs/Tvp/TvpColumnScalingBench.cs @@ -90,4 +90,4 @@ public int Tvp_10_Columns() count++; return count; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs index fbdb7a3..c7c0cd2 100644 --- a/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs +++ b/Benchmark/Workshops/Benchs/Tvp/TvpSerializationBench.cs @@ -53,4 +53,4 @@ public int Serialize_And_Materialize_ToArray() var records = firstItem.MapAsSqlDataRecords(_items).ToArray(); return records.Length; } -} \ No newline at end of file +} diff --git a/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs b/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs index 7e596bc..d9fce05 100644 --- a/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs +++ b/Benchmark/Workshops/Benchs/Tvp/TvpVsDataTableBench.cs @@ -112,4 +112,4 @@ public int DataTable_FillRows_BeginLoadData() dt.EndLoadData(); return dt.Rows.Count; } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs index fb15e2a..2b1aed0 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/AppHost.cs @@ -23,4 +23,4 @@ .WithReference(caeriusNet) .WaitFor(caeriusNet); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/GlobalUsings.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/GlobalUsings.cs index a847c9e..49c4387 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/GlobalUsings.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.AppHost/GlobalUsings.cs @@ -1 +1 @@ -global using Projects; \ No newline at end of file +global using Projects; diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs index e87c047..1bf5848 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/MultiResultSetDemo.cs @@ -39,4 +39,4 @@ internal static async Task RunAsync(IUsersService users, CancellationToken ct) $" ➤ TVP+MultiRS: {selectedUsers.Count} users matched, " + $"{theirOrders.Count} orders returned."); } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs index d3e4960..91205a5 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/ReadDemo.cs @@ -49,4 +49,4 @@ private static void Print(string label, IEnumerable items) foreach (var item in items) System.Console.WriteLine($" {item}"); } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs index 5c6e9d3..bd6f313 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TransactionDemo.cs @@ -66,4 +66,4 @@ await users.DemonstrateServerSideRollbackAsync( $"{ex.InnerException?.Message}"); } } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs index 43091ab..b246079 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoSections/TvpReadDemo.cs @@ -41,4 +41,4 @@ private static void PrintArray(string label, IEnumerable items) { Print(label, items); } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs index be129fd..4e26fa1 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/DemoWorker.cs @@ -57,4 +57,4 @@ private static async Task Section(string title, Func body) System.Console.WriteLine(new string('=', 76)); await body(); } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs index 76d91bc..021dd90 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/GlobalUsings.cs @@ -6,4 +6,4 @@ global using CaeriusNet.Telemetry; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; \ No newline at end of file +global using Microsoft.Extensions.Logging; diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs index 8d58707..3e1e5d0 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.Console/Program.cs @@ -21,4 +21,4 @@ LoggerProvider.SetLogger(app.Services.GetRequiredService>()); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs index d6520b9..2d9ca07 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/Extensions.cs @@ -120,4 +120,4 @@ private TBuilder AddDefaultHealthChecks() return builder; } } -} \ No newline at end of file +} diff --git a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/GlobalUsings.cs b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/GlobalUsings.cs index 0da3846..aed5b5b 100644 --- a/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/GlobalUsings.cs +++ b/Exemples/Aspire/CaeriusNet.Exemples.Aspire.ServiceDefaults/GlobalUsings.cs @@ -6,4 +6,4 @@ global using Microsoft.Extensions.Logging; global using OpenTelemetry; global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; \ No newline at end of file +global using OpenTelemetry.Trace; diff --git a/Exemples/Default/CaeriusNet.Exemples.Default.Console/GlobalUsings.cs b/Exemples/Default/CaeriusNet.Exemples.Default.Console/GlobalUsings.cs index d1640e0..1846a49 100644 --- a/Exemples/Default/CaeriusNet.Exemples.Default.Console/GlobalUsings.cs +++ b/Exemples/Default/CaeriusNet.Exemples.Default.Console/GlobalUsings.cs @@ -4,4 +4,4 @@ global using CaeriusNet.Logging; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; \ No newline at end of file +global using Microsoft.Extensions.Logging; diff --git a/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs b/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs index 118a687..1fb6804 100644 --- a/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs +++ b/Exemples/Default/CaeriusNet.Exemples.Default.Console/Program.cs @@ -113,4 +113,4 @@ static void Print(string label, IEnumerable items) Console.WriteLine($" ➤ {label}"); foreach (var item in items) Console.WriteLine($" {item}"); -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs index d4bb4a5..4d96c99 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/DashboardSnapshot.cs @@ -8,4 +8,4 @@ public sealed record DashboardSnapshot( IReadOnlyCollection Users, IReadOnlyCollection Orders, - IReadOnlyCollection Stats); \ No newline at end of file + IReadOnlyCollection Stats); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs index fb7cd54..9fbbcee 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersRepository.cs @@ -40,4 +40,4 @@ Task CreateUserWithFirstOrderAsync(string userName, string firstOrderLabel, /// and re-throws — the call surfaces as a . /// Task DemonstrateServerSideRollbackAsync(string userName, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs index d5b0507..c8182b8 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Abstractions/IUsersService.cs @@ -68,4 +68,4 @@ Task CreateUserWithFirstOrderAsync(string userName, string orderLabel, deci /// demonstrating SQL Server-side rollback surfaced as a . /// Task DemonstrateServerSideRollbackAsync(string userName, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs index b584550..8d30464 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Bootstrap/ExampleDatabaseBootstrapper.cs @@ -74,4 +74,4 @@ private static IEnumerable SplitBatches(string script) if (current.Length > 0) yield return current.ToString(); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs index a572782..0ef2afc 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Extensions/ServiceCollectionExtension.cs @@ -8,4 +8,4 @@ public static IServiceCollection AddDependenciesInjections(this IServiceCollecti .AddScoped() .AddScoped(); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs index 8ba517f..6e853e7 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/GlobalUsings.cs @@ -13,4 +13,4 @@ global using CaeriusNet.Exemples.Libs.Commons.Services; global using Microsoft.Extensions.DependencyInjection; global using System.Collections.Immutable; -global using System.Data; \ No newline at end of file +global using System.Data; diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs index 5b5fdc4..b38adf3 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/OrderDto.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Dtos; [GenerateDto] -public sealed partial record OrderDto(int OrderId, int UserId, string Label, decimal Amount, DateTime CreatedAt); \ No newline at end of file +public sealed partial record OrderDto(int OrderId, int UserId, string Label, decimal Amount, DateTime CreatedAt); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserDto.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserDto.cs index e196d60..6693197 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserDto.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserDto.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Dtos; [GenerateDto] -public sealed partial record UserDto(int UserId, Guid UserGuid); \ No newline at end of file +public sealed partial record UserDto(int UserId, Guid UserGuid); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs index f242efb..d68abad 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Dtos/UserStatsDto.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Dtos; [GenerateDto] -public sealed partial record UserStatsDto(int UserId, string UserName, int OrdersCount, decimal TotalAmount); \ No newline at end of file +public sealed partial record UserStatsDto(int UserId, string UserName, int OrdersCount, decimal TotalAmount); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersGuidTvp.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersGuidTvp.cs index a4be30c..f748ea1 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersGuidTvp.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersGuidTvp.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Tvps; [GenerateTvp(Schema = "Types", TvpName = "tvp_Guid")] -public sealed partial record UsersGuidTvp(Guid UserGuid); \ No newline at end of file +public sealed partial record UsersGuidTvp(Guid UserGuid); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntGuidTvp.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntGuidTvp.cs index 6c8aa1e..34bcb7c 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntGuidTvp.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntGuidTvp.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Tvps; [GenerateTvp(Schema = "Types", TvpName = "tvp_IntGuid")] -public sealed partial record UsersIntGuidTvp(int UserId, Guid UserGuid); \ No newline at end of file +public sealed partial record UsersIntGuidTvp(int UserId, Guid UserGuid); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntTvp.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntTvp.cs index f3fe27d..ff69616 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntTvp.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Models/Tvps/UsersIntTvp.cs @@ -1,4 +1,4 @@ namespace CaeriusNet.Exemples.Libs.Commons.Models.Tvps; [GenerateTvp(Schema = "Types", TvpName = "tvp_Int")] -public sealed partial record UsersIntTvp(int UserId); \ No newline at end of file +public sealed partial record UsersIntTvp(int UserId); diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs index c0b0dd5..fe78fd3 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.MultiResultSets.cs @@ -38,4 +38,4 @@ public async Task GetDashboardAsync(CancellationToken cancell return (users, orders); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs index 1c42dd6..713eba7 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Reads.cs @@ -60,4 +60,4 @@ public async Task CreateNewUser(CancellationToken cancellationToken = default) await dbContext.ExecuteAsync(sp, cancellationToken); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs index 8e6acef..eb5e9a4 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Transactions.cs @@ -98,4 +98,4 @@ public async Task DemonstrateServerSideRollbackAsync( // ActivityStatusCode.Error on the span and attaches the exception event. await dbContext.ExecuteAsync(sp, cancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs index 2ba54f4..b77b76a 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.Tvp.cs @@ -56,4 +56,4 @@ public async Task> GetUsersByTvpIntGuid(CancellationToken c return await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken); } -} \ No newline at end of file +} diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs index 1e95f02..572614e 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Repositories/UsersRepository.cs @@ -11,4 +11,4 @@ /// UsersRepository.Transactions.cs — transactional write scenarios /// /// -public sealed partial class UsersRepository(ICaeriusNetDbContext dbContext) : IUsersRepository; \ No newline at end of file +public sealed partial class UsersRepository(ICaeriusNetDbContext dbContext) : IUsersRepository; diff --git a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs index 884f3d7..c1cf626 100644 --- a/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs +++ b/Exemples/Libs/CaeriusNet.Exemples.Libs.Commons/Services/UsersService.cs @@ -76,4 +76,4 @@ public Task DemonstrateServerSideRollbackAsync(string userName, CancellationToke { return repository.DemonstrateServerSideRollbackAsync(userName, cancellationToken); } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs b/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs index 2c176d2..b387b16 100644 --- a/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs +++ b/SourceGenerators/AutoContracts/AutoContractsCacheKeyEmitter.cs @@ -225,4 +225,4 @@ private static void AppendHelpers(StringBuilder sb) sb.AppendLine(" hash.AppendData(value);"); sb.AppendLine(" }"); } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs b/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs index d96ab8a..702d2b3 100644 --- a/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs +++ b/SourceGenerators/AutoContracts/AutoContractsClrTypes.cs @@ -14,4 +14,4 @@ internal static bool IsReferenceType(string clrType) var normalized = clrType.TrimEnd('?'); return normalized is "string" or "byte[]" or "object" || normalized.Contains("<"); } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs b/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs index a16312c..18f11b4 100644 --- a/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs +++ b/SourceGenerators/AutoContracts/AutoContractsGeneratedSource.cs @@ -57,4 +57,4 @@ private static void AppendNamespace(StringBuilder sb, string ns) sb.Append("namespace ").Append(ns).AppendLine(";"); sb.AppendLine(); } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs b/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs index 5832bf4..5cc960f 100644 --- a/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs +++ b/SourceGenerators/AutoContracts/AutoContractsManifestParser.cs @@ -463,4 +463,4 @@ internal bool TryGetObject(string name, out JsonObject obj) return false; } } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsModels.cs b/SourceGenerators/AutoContracts/AutoContractsModels.cs index 4e19482..d20250a 100644 --- a/SourceGenerators/AutoContracts/AutoContractsModels.cs +++ b/SourceGenerators/AutoContracts/AutoContractsModels.cs @@ -48,4 +48,4 @@ internal sealed record AutoContractsColumn( bool Nullable, int? MaxLength, byte? Precision, - byte? Scale); \ No newline at end of file + byte? Scale); diff --git a/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs b/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs index 1d74a53..0a3ded4 100644 --- a/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs +++ b/SourceGenerators/AutoContracts/AutoContractsSourceNames.cs @@ -8,4 +8,4 @@ internal static string BuildHintName(AutoContractsManifest manifest) { return HintNameBuilder.Build(manifest.Namespace, "CaeriusContracts", "AutoContracts"); } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs b/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs index 20df1f0..c1f2235 100644 --- a/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs +++ b/SourceGenerators/AutoContracts/AutoContractsSqlEmitter.cs @@ -241,4 +241,4 @@ private static bool IsKeyword(string identifier) return identifier is "class" or "struct" or "record" or "namespace" or "public" or "private" or "internal" or "readonly" or "string" or "int" or "bool" or "decimal" or "object"; } -} \ No newline at end of file +} diff --git a/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs b/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs index 4eb317b..6c98c91 100644 --- a/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs +++ b/SourceGenerators/AutoContracts/AutoContractsSqlFacets.cs @@ -59,4 +59,4 @@ private static bool IsScaledType(string normalizedSqlType) { return normalizedSqlType is "decimal" or "numeric" or "time" or "datetime2" or "datetimeoffset"; } -} \ No newline at end of file +} diff --git a/SourceGenerators/Dto/DtoEmitter.cs b/SourceGenerators/Dto/DtoEmitter.cs index bbb11cd..7507ff8 100644 --- a/SourceGenerators/Dto/DtoEmitter.cs +++ b/SourceGenerators/Dto/DtoEmitter.cs @@ -118,4 +118,4 @@ private static string Capitalize(string name) if (string.IsNullOrEmpty(name) || char.IsUpper(name[0])) return name; return char.ToUpperInvariant(name[0]) + name.Substring(1); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Dto/DtoExtractor.cs b/SourceGenerators/Dto/DtoExtractor.cs index fc4ba63..3740c8c 100644 --- a/SourceGenerators/Dto/DtoExtractor.cs +++ b/SourceGenerators/Dto/DtoExtractor.cs @@ -74,4 +74,4 @@ private static EquatableArray ExtractColumns( return new EquatableArray(builder.ToImmutable()); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Dto/DtoSourceGenerator.cs b/SourceGenerators/Dto/DtoSourceGenerator.cs index 4a9a986..88ec756 100644 --- a/SourceGenerators/Dto/DtoSourceGenerator.cs +++ b/SourceGenerators/Dto/DtoSourceGenerator.cs @@ -58,4 +58,4 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(HintNameBuilder.Build(model.Namespace, model.TypeName, "Dto"), DtoEmitter.Emit(model)); }); } -} \ No newline at end of file +} diff --git a/SourceGenerators/GlobalUsings.cs b/SourceGenerators/GlobalUsings.cs index ea34138..8adc9c7 100644 --- a/SourceGenerators/GlobalUsings.cs +++ b/SourceGenerators/GlobalUsings.cs @@ -10,4 +10,4 @@ global using System.Linq; global using System.Runtime.CompilerServices; global using System.Text; -global using System.Threading; \ No newline at end of file +global using System.Threading; diff --git a/SourceGenerators/Helpers/ColumnExtractor.cs b/SourceGenerators/Helpers/ColumnExtractor.cs index e9d0cef..fee9f36 100644 --- a/SourceGenerators/Helpers/ColumnExtractor.cs +++ b/SourceGenerators/Helpers/ColumnExtractor.cs @@ -86,4 +86,4 @@ private static ColumnKind ClassifyKind(ITypeSymbol type) _ => ColumnKind.Standard }; } -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/GeneratedCodeInfo.cs b/SourceGenerators/Helpers/GeneratedCodeInfo.cs index ddfe5d3..0ae7c47 100644 --- a/SourceGenerators/Helpers/GeneratedCodeInfo.cs +++ b/SourceGenerators/Helpers/GeneratedCodeInfo.cs @@ -4,4 +4,4 @@ internal static class GeneratedCodeInfo { internal const string ToolName = "CaeriusNet.Generator"; internal const string Version = "11.0.0"; -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/HintNameBuilder.cs b/SourceGenerators/Helpers/HintNameBuilder.cs index 377fae0..8115e18 100644 --- a/SourceGenerators/Helpers/HintNameBuilder.cs +++ b/SourceGenerators/Helpers/HintNameBuilder.cs @@ -11,4 +11,4 @@ internal static string Build(string @namespace, string typeName, string generato var namespacePrefix = string.IsNullOrEmpty(@namespace) ? "global" : @namespace; return $"{namespacePrefix}.{typeName}.{generatorKind}.g.cs"; } -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/IsExternalInit.cs b/SourceGenerators/Helpers/IsExternalInit.cs index 0167321..8a6eeb3 100644 --- a/SourceGenerators/Helpers/IsExternalInit.cs +++ b/SourceGenerators/Helpers/IsExternalInit.cs @@ -4,4 +4,4 @@ namespace System.Runtime.CompilerServices; -internal static class IsExternalInit; \ No newline at end of file +internal static class IsExternalInit; diff --git a/SourceGenerators/Helpers/NamespaceHelper.cs b/SourceGenerators/Helpers/NamespaceHelper.cs index 31a9ed1..429c2b2 100644 --- a/SourceGenerators/Helpers/NamespaceHelper.cs +++ b/SourceGenerators/Helpers/NamespaceHelper.cs @@ -11,4 +11,4 @@ internal static string GetNamespace(INamedTypeSymbol typeSymbol) ? string.Empty : typeSymbol.ContainingNamespace.ToDisplayString(); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/SqlMetaDataExpressionBuilder.cs b/SourceGenerators/Helpers/SqlMetaDataExpressionBuilder.cs index 1ba5458..8c15253 100644 --- a/SourceGenerators/Helpers/SqlMetaDataExpressionBuilder.cs +++ b/SourceGenerators/Helpers/SqlMetaDataExpressionBuilder.cs @@ -35,4 +35,4 @@ internal static string Build(string columnName, string sqlType) _ => $"new SqlMetaData(\"{columnName}\", SqlDbType.Variant)" }; } -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/TypeDetector.cs b/SourceGenerators/Helpers/TypeDetector.cs index 8c0ba95..d29311f 100644 --- a/SourceGenerators/Helpers/TypeDetector.cs +++ b/SourceGenerators/Helpers/TypeDetector.cs @@ -272,4 +272,4 @@ private static ReadOnlySpan ExtractBaseTypeFromNullable(ReadOnlySpan return typeName; } -} \ No newline at end of file +} diff --git a/SourceGenerators/Helpers/TypeStructureValidator.cs b/SourceGenerators/Helpers/TypeStructureValidator.cs index 7d5dffa..84dd17b 100644 --- a/SourceGenerators/Helpers/TypeStructureValidator.cs +++ b/SourceGenerators/Helpers/TypeStructureValidator.cs @@ -63,4 +63,4 @@ internal readonly record struct ValidationResult( bool IsTopLevel, bool IsNonGeneric, TypeDeclarationSyntax? PrimaryConstructorDeclaration); -} \ No newline at end of file +} diff --git a/SourceGenerators/Models/ColumnKind.cs b/SourceGenerators/Models/ColumnKind.cs index 93eea39..ff9d86b 100644 --- a/SourceGenerators/Models/ColumnKind.cs +++ b/SourceGenerators/Models/ColumnKind.cs @@ -30,4 +30,4 @@ internal enum ColumnKind /// System.Half — read via (Half)GetFloat(i), written via SetFloat(i, (float)value). Half = 6 -} \ No newline at end of file +} diff --git a/SourceGenerators/Models/ColumnModel.cs b/SourceGenerators/Models/ColumnModel.cs index 41aa852..f97b888 100644 --- a/SourceGenerators/Models/ColumnModel.cs +++ b/SourceGenerators/Models/ColumnModel.cs @@ -24,4 +24,4 @@ internal sealed record ColumnModel( string ReaderMethod, string SqlMetaDataConstructor, ColumnKind Kind, - string? EnumUnderlyingType); \ No newline at end of file + string? EnumUnderlyingType); diff --git a/SourceGenerators/Models/DtoModel.cs b/SourceGenerators/Models/DtoModel.cs index 12cabd8..59e0d14 100644 --- a/SourceGenerators/Models/DtoModel.cs +++ b/SourceGenerators/Models/DtoModel.cs @@ -15,4 +15,4 @@ internal sealed record DtoModel( string TypeName, string AccessibilityKeyword, string TypeKindKeyword, - EquatableArray Columns); \ No newline at end of file + EquatableArray Columns); diff --git a/SourceGenerators/Models/EquatableArray.cs b/SourceGenerators/Models/EquatableArray.cs index dbef3f6..a01f9de 100644 --- a/SourceGenerators/Models/EquatableArray.cs +++ b/SourceGenerators/Models/EquatableArray.cs @@ -89,4 +89,4 @@ IEnumerator IEnumerable.GetEnumerator() { return !left.Equals(right); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Models/TvpModel.cs b/SourceGenerators/Models/TvpModel.cs index f1083b5..1f1d795 100644 --- a/SourceGenerators/Models/TvpModel.cs +++ b/SourceGenerators/Models/TvpModel.cs @@ -15,4 +15,4 @@ internal sealed record TvpModel( string TypeKindKeyword, string Schema, string TvpName, - EquatableArray Columns); \ No newline at end of file + EquatableArray Columns); diff --git a/SourceGenerators/Tvp/TvpEmitter.cs b/SourceGenerators/Tvp/TvpEmitter.cs index 8583089..775f57f 100644 --- a/SourceGenerators/Tvp/TvpEmitter.cs +++ b/SourceGenerators/Tvp/TvpEmitter.cs @@ -187,4 +187,4 @@ private static string ToStringLiteral(string value) sb.Append('"'); return sb.ToString(); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Tvp/TvpExtractor.cs b/SourceGenerators/Tvp/TvpExtractor.cs index 2f33ac3..3492a81 100644 --- a/SourceGenerators/Tvp/TvpExtractor.cs +++ b/SourceGenerators/Tvp/TvpExtractor.cs @@ -99,4 +99,4 @@ private static EquatableArray ExtractColumns( return new EquatableArray(builder.ToImmutable()); } -} \ No newline at end of file +} diff --git a/SourceGenerators/Tvp/TvpSourceGenerator.cs b/SourceGenerators/Tvp/TvpSourceGenerator.cs index dd058d2..2f04359 100644 --- a/SourceGenerators/Tvp/TvpSourceGenerator.cs +++ b/SourceGenerators/Tvp/TvpSourceGenerator.cs @@ -34,4 +34,4 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(HintNameBuilder.Build(model.Namespace, model.TypeName, "Tvp"), TvpEmitter.Emit(model)); }); } -} \ No newline at end of file +} diff --git a/Src/Abstractions/ICaeriusNetCache.cs b/Src/Abstractions/ICaeriusNetCache.cs index 04de256..3b3544e 100644 --- a/Src/Abstractions/ICaeriusNetCache.cs +++ b/Src/Abstractions/ICaeriusNetCache.cs @@ -42,4 +42,4 @@ public interface ICaeriusNetCache /// When is not a known value. /// When attempting to clear Redis. ValueTask ClearAsync(CacheType cacheType, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Src/Abstractions/ICaeriusNetDbContext.cs b/Src/Abstractions/ICaeriusNetDbContext.cs index f494aed..2e63930 100644 --- a/Src/Abstractions/ICaeriusNetDbContext.cs +++ b/Src/Abstractions/ICaeriusNetDbContext.cs @@ -44,4 +44,4 @@ public interface ICaeriusNetDbContext /// /// ValueTask DbConnectionAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Src/Abstractions/ICaeriusNetTransaction.cs b/Src/Abstractions/ICaeriusNetTransaction.cs index bea086d..6a9d434 100644 --- a/Src/Abstractions/ICaeriusNetTransaction.cs +++ b/Src/Abstractions/ICaeriusNetTransaction.cs @@ -76,4 +76,4 @@ internal interface ICaeriusNetTransactionInternal : ICaeriusNetTransaction /// will throw; only Rollback / Dispose remain valid. /// void Poison(); -} \ No newline at end of file +} diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs index 15a037d..1c6d7ed 100644 --- a/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs +++ b/Src/Attributes/AutoContracts/AutoContractGenerateDtoAttribute.cs @@ -17,4 +17,4 @@ public sealed class AutoContractGenerateDtoAttribute : Attribute /// Gets or sets the manifest contract hash. public string ContractHash { get; set; } = string.Empty; -} \ No newline at end of file +} diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs index 77dc006..c06761d 100644 --- a/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs +++ b/Src/Attributes/AutoContracts/AutoContractGenerateProcedureAttribute.cs @@ -14,4 +14,4 @@ public sealed class AutoContractGenerateProcedureAttribute : Attribute /// Gets or sets the manifest contract hash. public string ContractHash { get; set; } = string.Empty; -} \ No newline at end of file +} diff --git a/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs b/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs index 9fc16bd..5239f2b 100644 --- a/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs +++ b/Src/Attributes/AutoContracts/AutoContractGenerateTvpAttribute.cs @@ -14,4 +14,4 @@ public sealed class AutoContractGenerateTvpAttribute : Attribute /// Gets or sets the manifest contract hash. public string ContractHash { get; set; } = string.Empty; -} \ No newline at end of file +} diff --git a/Src/Attributes/Dto/GenerateDtoAttribute.cs b/Src/Attributes/Dto/GenerateDtoAttribute.cs index e0ed484..ac52f97 100644 --- a/Src/Attributes/Dto/GenerateDtoAttribute.cs +++ b/Src/Attributes/Dto/GenerateDtoAttribute.cs @@ -36,4 +36,4 @@ /// 3. The generated ISpMapper<T> implementation appears at compile time. /// [AttributeUsage(AttributeTargets.Class, Inherited = false)] -public sealed class GenerateDtoAttribute : Attribute; \ No newline at end of file +public sealed class GenerateDtoAttribute : Attribute; diff --git a/Src/Attributes/Tvp/GenerateTvpAttribute.cs b/Src/Attributes/Tvp/GenerateTvpAttribute.cs index aeef47b..80ca113 100644 --- a/Src/Attributes/Tvp/GenerateTvpAttribute.cs +++ b/Src/Attributes/Tvp/GenerateTvpAttribute.cs @@ -50,4 +50,4 @@ public sealed class GenerateTvpAttribute : Attribute /// Gets or sets the SQL Server table type name (without schema) to use for this TVP. /// public required string TvpName { get; init; } -} \ No newline at end of file +} diff --git a/Src/Builders/CaeriusNetBuilder.cs b/Src/Builders/CaeriusNetBuilder.cs index a60c700..accca7b 100644 --- a/Src/Builders/CaeriusNetBuilder.cs +++ b/Src/Builders/CaeriusNetBuilder.cs @@ -199,4 +199,4 @@ private void ConfigureRedis() new RedisCacheManager(null, provider.GetService())); } } -} \ No newline at end of file +} diff --git a/Src/Builders/StoredProcedureParametersBuilder.Typed.cs b/Src/Builders/StoredProcedureParametersBuilder.Typed.cs index 03c15fc..ba8ce29 100644 --- a/Src/Builders/StoredProcedureParametersBuilder.Typed.cs +++ b/Src/Builders/StoredProcedureParametersBuilder.Typed.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Diagnostics.CodeAnalysis; namespace CaeriusNet.Builders; diff --git a/Src/Builders/StoredProcedureParametersBuilder.cs b/Src/Builders/StoredProcedureParametersBuilder.cs index cdd7f2a..e374b7e 100644 --- a/Src/Builders/StoredProcedureParametersBuilder.cs +++ b/Src/Builders/StoredProcedureParametersBuilder.cs @@ -213,4 +213,4 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } } -} \ No newline at end of file +} diff --git a/Src/Caches/CaeriusNetCache.cs b/Src/Caches/CaeriusNetCache.cs index 4416bcf..eea79e3 100644 --- a/Src/Caches/CaeriusNetCache.cs +++ b/Src/Caches/CaeriusNetCache.cs @@ -62,4 +62,4 @@ public ValueTask ClearAsync(CacheType cacheType, CancellationToken cancellationT return ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/Src/Caches/FrozenCacheManager.cs b/Src/Caches/FrozenCacheManager.cs index 2b50256..9821463 100644 --- a/Src/Caches/FrozenCacheManager.cs +++ b/Src/Caches/FrozenCacheManager.cs @@ -203,4 +203,4 @@ internal static void Clear() LockSlim.ExitWriteLock(); } } -} \ No newline at end of file +} diff --git a/Src/Caches/InMemoryCacheManager.cs b/Src/Caches/InMemoryCacheManager.cs index 464f47d..f375ce1 100644 --- a/Src/Caches/InMemoryCacheManager.cs +++ b/Src/Caches/InMemoryCacheManager.cs @@ -109,4 +109,4 @@ internal static void Clear() { _memoryCache.Clear(); } -} \ No newline at end of file +} diff --git a/Src/Caches/RedisCacheManager.cs b/Src/Caches/RedisCacheManager.cs index 3bc4c53..21a298d 100644 --- a/Src/Caches/RedisCacheManager.cs +++ b/Src/Caches/RedisCacheManager.cs @@ -57,11 +57,13 @@ public RedisCacheManager(IDistributedCache? distributedCache, ILoggerFactory? lo [UnconditionalSuppressMessage( "Trimming", "IL2026", - Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] + Justification = + "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] [UnconditionalSuppressMessage( "AOT", "IL3050", - Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] + Justification = + "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] [MethodImpl(MethodImplOptions.AggressiveOptimization)] public void Store(string cacheKey, T value, TimeSpan? expiration) where T : notnull { @@ -144,11 +146,13 @@ public void Store(string cacheKey, T value, TimeSpan? expiration, JsonTypeInf [UnconditionalSuppressMessage( "Trimming", "IL2026", - Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] + Justification = + "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for trim-safe callers.")] [UnconditionalSuppressMessage( "AOT", "IL3050", - Justification = "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] + Justification = + "Legacy Redis cache overload keeps existing API behavior; source-generated JsonTypeInfo overload is available for Native AOT callers.")] [MethodImpl(MethodImplOptions.AggressiveOptimization)] public bool TryGet(string cacheKey, out T? value) { diff --git a/Src/Commands/Reads/MultiIEnumerableReadSqlAsyncCommands.cs b/Src/Commands/Reads/MultiIEnumerableReadSqlAsyncCommands.cs index 39e87f1..c04b6b9 100644 --- a/Src/Commands/Reads/MultiIEnumerableReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/MultiIEnumerableReadSqlAsyncCommands.cs @@ -177,4 +177,4 @@ public static class MultiIEnumerableReadSqlAsyncCommands }, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs b/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs index b1d0738..e987ecb 100644 --- a/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/MultiImmutableArrayReadSqlAsyncCommands.cs @@ -182,4 +182,4 @@ public static class MultiImmutableArrayReadSqlAsyncCommands }, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs b/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs index b088ed6..603de02 100644 --- a/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/MultiReadOnlyCollectionReadSqlAsyncCommands.cs @@ -193,4 +193,4 @@ public static class MultiReadOnlyCollectionReadSqlAsyncCommands }, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs b/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs index dc5c394..62830a4 100644 --- a/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs +++ b/Src/Commands/Reads/SimpleReadSqlAsyncCommands.cs @@ -311,4 +311,4 @@ public async ValueTask> QueryAsImmutableArrayAsync BeginTransactionAsync( "Nested transactions are not supported. Use SAVEPOINTs in stored procedures for partial rollback."); } } -} \ No newline at end of file +} diff --git a/Src/Commands/Transactions/TransactionReadSqlAsyncCommands.cs b/Src/Commands/Transactions/TransactionReadSqlAsyncCommands.cs index ff51938..754343e 100644 --- a/Src/Commands/Transactions/TransactionReadSqlAsyncCommands.cs +++ b/Src/Commands/Transactions/TransactionReadSqlAsyncCommands.cs @@ -190,4 +190,4 @@ public async ValueTask> QueryAsImmutableArrayAsyncThe that is the cause of the current exception. /// Represents the original SQL exception encountered during the operation. public sealed class CaeriusNetSqlException(string message, SqlException innerException) - : Exception(message, innerException); \ No newline at end of file + : Exception(message, innerException); diff --git a/Src/Factories/CaeriusNetDbContext.cs b/Src/Factories/CaeriusNetDbContext.cs index 78d4f24..2be9fbe 100644 --- a/Src/Factories/CaeriusNetDbContext.cs +++ b/Src/Factories/CaeriusNetDbContext.cs @@ -46,4 +46,4 @@ public async ValueTask DbConnectionAsync(CancellationToken cancel throw new CaeriusNetSqlException("Failed to open database connection", ex); } } -} \ No newline at end of file +} diff --git a/Src/Factories/CaeriusNetTransaction.cs b/Src/Factories/CaeriusNetTransaction.cs index 4ccc8bf..4c1f9a9 100644 --- a/Src/Factories/CaeriusNetTransaction.cs +++ b/Src/Factories/CaeriusNetTransaction.cs @@ -224,4 +224,4 @@ private static string StateName(int state) _ => "Unknown" }; } -} \ No newline at end of file +} diff --git a/Src/Helpers/CacheHelper.cs b/Src/Helpers/CacheHelper.cs index 10435ed..400504c 100644 --- a/Src/Helpers/CacheHelper.cs +++ b/Src/Helpers/CacheHelper.cs @@ -96,4 +96,4 @@ internal static void StoreInCache( "Unsupported cache type value."); } } -} \ No newline at end of file +} diff --git a/Src/Helpers/CacheType.cs b/Src/Helpers/CacheType.cs index 0d06d2d..4de32cc 100644 --- a/Src/Helpers/CacheType.cs +++ b/Src/Helpers/CacheType.cs @@ -24,4 +24,4 @@ public enum CacheType : byte /// Suitable for distributed systems requiring cache coherence. /// Redis -} \ No newline at end of file +} diff --git a/Src/Helpers/EmptyCollections.cs b/Src/Helpers/EmptyCollections.cs index ddd3c95..ce9b7be 100644 --- a/Src/Helpers/EmptyCollections.cs +++ b/Src/Helpers/EmptyCollections.cs @@ -36,4 +36,4 @@ private static class EmptyReadOnlyCollection public static readonly ReadOnlyCollection Instance = new(Array.Empty()); } -} \ No newline at end of file +} diff --git a/Src/Helpers/MultiResultSetHelper.cs b/Src/Helpers/MultiResultSetHelper.cs index 0689c85..09c03fb 100644 --- a/Src/Helpers/MultiResultSetHelper.cs +++ b/Src/Helpers/MultiResultSetHelper.cs @@ -105,4 +105,4 @@ private static int GrowCapacity(int capacity) { return capacity <= 1 ? 2 : capacity * 3 / 2; } -} \ No newline at end of file +} diff --git a/Src/Helpers/SqlCommandHelper.cs b/Src/Helpers/SqlCommandHelper.cs index e9a28c1..340f3ae 100644 --- a/Src/Helpers/SqlCommandHelper.cs +++ b/Src/Helpers/SqlCommandHelper.cs @@ -420,4 +420,4 @@ internal static async ValueTask ExecuteCommandAsync( throw new CaeriusNetSqlException($"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); } } -} \ No newline at end of file +} diff --git a/Src/Helpers/SqlCommandHelperTx.cs b/Src/Helpers/SqlCommandHelperTx.cs index 75525cb..8bc3edb 100644 --- a/Src/Helpers/SqlCommandHelperTx.cs +++ b/Src/Helpers/SqlCommandHelperTx.cs @@ -160,4 +160,4 @@ internal static async ValueTask ExecuteCommandTxAsync( throw new CaeriusNetSqlException($"Failed to execute stored procedure: {spParameters.ProcedureName}", ex); } } -} \ No newline at end of file +} diff --git a/Src/Logging/LogMessages.cs b/Src/Logging/LogMessages.cs index 8345088..1349b41 100644 --- a/Src/Logging/LogMessages.cs +++ b/Src/Logging/LogMessages.cs @@ -626,4 +626,4 @@ public static partial void LogCacheHitSkippingExecution( string cacheKey); #endregion -} \ No newline at end of file +} diff --git a/Src/Logging/LoggerProvider.cs b/Src/Logging/LoggerProvider.cs index a488fde..7477a39 100644 --- a/Src/Logging/LoggerProvider.cs +++ b/Src/Logging/LoggerProvider.cs @@ -61,4 +61,4 @@ public static void SetLogger(ILogger logger) { Volatile.Write(ref _logger, logger); } -} \ No newline at end of file +} diff --git a/Src/Mappers/ICaeriusGeneratedProcedure.cs b/Src/Mappers/ICaeriusGeneratedProcedure.cs index 664df58..ee938e3 100644 --- a/Src/Mappers/ICaeriusGeneratedProcedure.cs +++ b/Src/Mappers/ICaeriusGeneratedProcedure.cs @@ -24,4 +24,4 @@ public interface ICaeriusGeneratedProcedure /// Gets the number of generated result sets supported by this descriptor. public static abstract int ResultSetCount { get; } -} \ No newline at end of file +} diff --git a/Src/Mappers/ISpMapper.cs b/Src/Mappers/ISpMapper.cs index 42967b5..6729fb2 100644 --- a/Src/Mappers/ISpMapper.cs +++ b/Src/Mappers/ISpMapper.cs @@ -55,4 +55,4 @@ public interface ISpMapper where T : class /// Thrown when the reader is not positioned on a valid row. /// Thrown when data conversion between SQL and .NET types fails. public static abstract T MapFromDataReader(SqlDataReader reader); -} \ No newline at end of file +} diff --git a/Src/Mappers/ITvpMapper.cs b/Src/Mappers/ITvpMapper.cs index 1834192..6b584ef 100644 --- a/Src/Mappers/ITvpMapper.cs +++ b/Src/Mappers/ITvpMapper.cs @@ -28,4 +28,4 @@ public interface ITvpMapper where T : class /// making this single-instance reuse pattern safe for TVP streaming. /// public IEnumerable MapAsSqlDataRecords(IEnumerable items); -} \ No newline at end of file +} diff --git a/Src/Mappers/StoredProcedureParameters.Typed.cs b/Src/Mappers/StoredProcedureParameters.Typed.cs index de652b5..e367be2 100644 --- a/Src/Mappers/StoredProcedureParameters.Typed.cs +++ b/Src/Mappers/StoredProcedureParameters.Typed.cs @@ -68,4 +68,4 @@ public ReadOnlySpan GetParametersSpan() { return _parameters.GetParametersSpan(); } -} \ No newline at end of file +} diff --git a/Src/Mappers/StoredProcedureParameters.cs b/Src/Mappers/StoredProcedureParameters.cs index 47121a4..fd8abeb 100644 --- a/Src/Mappers/StoredProcedureParameters.cs +++ b/Src/Mappers/StoredProcedureParameters.cs @@ -81,4 +81,4 @@ private static SqlParameter CloneParameter(SqlParameter parameter) clone.Value ??= DBNull.Value; return clone; } -} \ No newline at end of file +} diff --git a/Src/Telemetry/CaeriusActivityExtensions.cs b/Src/Telemetry/CaeriusActivityExtensions.cs index 9db0c5f..971cd98 100644 --- a/Src/Telemetry/CaeriusActivityExtensions.cs +++ b/Src/Telemetry/CaeriusActivityExtensions.cs @@ -318,4 +318,4 @@ internal static void RecordTransactionOutcome(Activity? activity, string outcome activity.SetStatus(isError ? ActivityStatusCode.Error : ActivityStatusCode.Ok, outcome); activity.Stop(); } -} \ No newline at end of file +} diff --git a/Src/Telemetry/CaeriusDiagnostics.cs b/Src/Telemetry/CaeriusDiagnostics.cs index 0112f38..e4c0644 100644 --- a/Src/Telemetry/CaeriusDiagnostics.cs +++ b/Src/Telemetry/CaeriusDiagnostics.cs @@ -126,4 +126,4 @@ public static class AttributeValues { public const string DbSystemMsSql = "mssql"; } -} \ No newline at end of file +} diff --git a/Src/Telemetry/CaeriusTelemetryOptions.cs b/Src/Telemetry/CaeriusTelemetryOptions.cs index f2cb7a1..d852268 100644 --- a/Src/Telemetry/CaeriusTelemetryOptions.cs +++ b/Src/Telemetry/CaeriusTelemetryOptions.cs @@ -23,4 +23,4 @@ public sealed class CaeriusTelemetryOptions /// production telemetry pipelines. /// public bool CaptureParameterValues { get; init; } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Analyzer.Tests/GeneratorUsageAnalyzerTests.cs b/Tests/CaeriusNet.Analyzer.Tests/GeneratorUsageAnalyzerTests.cs index 037e290..6757a16 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/GeneratorUsageAnalyzerTests.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/GeneratorUsageAnalyzerTests.cs @@ -233,4 +233,4 @@ private static void AssertDiagnostic( FilterCaeriusDiagnostics(diagnostics), diagnostic => diagnostic.Id == id && diagnostic.Severity == severity); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs index 66acd72..48f305c 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/GlobalUsings.cs @@ -8,4 +8,4 @@ global using Microsoft.CodeAnalysis.CSharp; global using Microsoft.CodeAnalysis.Diagnostics; global using Microsoft.CodeAnalysis.Text; -global using Xunit; \ No newline at end of file +global using Xunit; diff --git a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs index 3e93f23..1132e90 100644 --- a/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs +++ b/Tests/CaeriusNet.Analyzer.Tests/Utilities/AnalyzerTestHelper.cs @@ -29,7 +29,7 @@ public sealed class GenerateTvpAttribute : Attribute internal static IReadOnlyList RunAnalyzer(string source, params AdditionalText[] additionalTexts) { - return RunAnalyzer(source, globalOptions: null, additionalTexts); + return RunAnalyzer(source, null, additionalTexts); } internal static IReadOnlyList RunAnalyzer( diff --git a/Tests/CaeriusNet.Generator.Tests/Caching/IncrementalCachingTests.cs b/Tests/CaeriusNet.Generator.Tests/Caching/IncrementalCachingTests.cs index 7ee41eb..9f1dc42 100644 --- a/Tests/CaeriusNet.Generator.Tests/Caching/IncrementalCachingTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Caching/IncrementalCachingTests.cs @@ -62,4 +62,4 @@ private static void AssertExtractionCached(string source) reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, $"Expected Cached/Unchanged, got {reason}. A non-equatable value is flowing through the pipeline."); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Diagnostics/DiagnosticTests.cs b/Tests/CaeriusNet.Generator.Tests/Diagnostics/DiagnosticTests.cs index 96f1d1c..6ef871f 100644 --- a/Tests/CaeriusNet.Generator.Tests/Diagnostics/DiagnosticTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Diagnostics/DiagnosticTests.cs @@ -253,4 +253,4 @@ public sealed partial record BigNumTvp(int Id, System.Int128 BigValue); Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id.StartsWith("CAERIUS", StringComparison.Ordinal)); Assert.NotEmpty(RunTvpFull(source).GeneratedTrees); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Dto/DtoSourceGeneratorTests.cs b/Tests/CaeriusNet.Generator.Tests/Dto/DtoSourceGeneratorTests.cs index 40b39ff..349e5a8 100644 --- a/Tests/CaeriusNet.Generator.Tests/Dto/DtoSourceGeneratorTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Dto/DtoSourceGeneratorTests.cs @@ -633,4 +633,4 @@ public sealed partial record SimpleDto(int Id, string Name); var generated = result.GeneratedTrees[0].GetText().ToString(); Assert.Contains("/// ", generated); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs index c3227b9..9626fab 100644 --- a/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Generator.Tests/GlobalUsings.cs @@ -10,4 +10,4 @@ global using CaeriusNet.Generator.Tvp; global using CaeriusNet.Generator.AutoContracts; global using CaeriusNet.Generator.Helpers; -global using CaeriusNet.Generator.Tests.Utilities; \ No newline at end of file +global using CaeriusNet.Generator.Tests.Utilities; diff --git a/Tests/CaeriusNet.Generator.Tests/Helpers/NamespaceHelperTests.cs b/Tests/CaeriusNet.Generator.Tests/Helpers/NamespaceHelperTests.cs index 14a779d..94ecbb9 100644 --- a/Tests/CaeriusNet.Generator.Tests/Helpers/NamespaceHelperTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Helpers/NamespaceHelperTests.cs @@ -50,4 +50,4 @@ public void Returns_Single_Segment_For_Top_Level_Namespace() Assert.Equal("Foo", NamespaceHelper.GetNamespace(symbol)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Helpers/SqlMetaDataExpressionBuilderTests.cs b/Tests/CaeriusNet.Generator.Tests/Helpers/SqlMetaDataExpressionBuilderTests.cs index 0c27702..d058c1d 100644 --- a/Tests/CaeriusNet.Generator.Tests/Helpers/SqlMetaDataExpressionBuilderTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Helpers/SqlMetaDataExpressionBuilderTests.cs @@ -47,4 +47,4 @@ public void Build_Preserves_Column_Name_With_Special_Characters() var result = SqlMetaDataExpressionBuilder.Build("My Column", "int"); Assert.Equal("new SqlMetaData(\"My Column\", SqlDbType.Int)", result); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Helpers/TypeDetectorTests.cs b/Tests/CaeriusNet.Generator.Tests/Helpers/TypeDetectorTests.cs index d92bc26..85be906 100644 --- a/Tests/CaeriusNet.Generator.Tests/Helpers/TypeDetectorTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Helpers/TypeDetectorTests.cs @@ -56,4 +56,4 @@ public void RequiresSpecialConversion_Returns_Expected_Result(string typeName, b Assert.Equal(expected, actual); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Tvp/TvpSourceGeneratorTests.cs b/Tests/CaeriusNet.Generator.Tests/Tvp/TvpSourceGeneratorTests.cs index db613b4..d512762 100644 --- a/Tests/CaeriusNet.Generator.Tests/Tvp/TvpSourceGeneratorTests.cs +++ b/Tests/CaeriusNet.Generator.Tests/Tvp/TvpSourceGeneratorTests.cs @@ -508,4 +508,4 @@ public sealed partial record GenericTvp(int Id); Assert.Empty(result.GeneratedTrees); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs b/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs index 00cfe16..5a8c008 100644 --- a/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs +++ b/Tests/CaeriusNet.Generator.Tests/Utilities/SourceGeneratorTestHelper.cs @@ -126,4 +126,4 @@ public override SourceText GetText(CancellationToken cancellationToken = default { return SourceText.From(text, Encoding.UTF8); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Fixtures/SqlServerFixture.cs b/Tests/CaeriusNet.IntegrationTests/Fixtures/SqlServerFixture.cs index c4952b6..2582778 100644 --- a/Tests/CaeriusNet.IntegrationTests/Fixtures/SqlServerFixture.cs +++ b/Tests/CaeriusNet.IntegrationTests/Fixtures/SqlServerFixture.cs @@ -148,4 +148,4 @@ private static IEnumerable SplitOnGo(string script) public sealed class SqlServerCollection : ICollectionFixture { public const string Name = "SqlServer"; -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/GlobalUsings.cs b/Tests/CaeriusNet.IntegrationTests/GlobalUsings.cs index 7da6a1e..6c0346f 100644 --- a/Tests/CaeriusNet.IntegrationTests/GlobalUsings.cs +++ b/Tests/CaeriusNet.IntegrationTests/GlobalUsings.cs @@ -20,4 +20,4 @@ global using Microsoft.Data.SqlClient; global using Microsoft.Extensions.DependencyInjection; global using Testcontainers.MsSql; -global using Xunit; \ No newline at end of file +global using Xunit; diff --git a/Tests/CaeriusNet.IntegrationTests/Models/WidgetDto.cs b/Tests/CaeriusNet.IntegrationTests/Models/WidgetDto.cs index 45c1c05..e840bed 100644 --- a/Tests/CaeriusNet.IntegrationTests/Models/WidgetDto.cs +++ b/Tests/CaeriusNet.IntegrationTests/Models/WidgetDto.cs @@ -19,4 +19,4 @@ public sealed partial record WidgetCountDto(long Total); /// . /// [GenerateTvp(Schema = "dbo", TvpName = "WidgetTvp")] -public sealed partial record WidgetTvp(string Name, int Quantity); \ No newline at end of file +public sealed partial record WidgetTvp(string Name, int Quantity); diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs index aefc088..5417eb7 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/AutoContractsMetadataTests.cs @@ -372,4 +372,4 @@ private sealed record TableTypeColumn( byte Precision, byte Scale, bool IsNullable); -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/CacheIntegrationTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/CacheIntegrationTests.cs index afb4b9b..d38ea78 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/CacheIntegrationTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/CacheIntegrationTests.cs @@ -182,4 +182,4 @@ public async Task RemoveAsync_Empty_Key_Throws() await Assert.ThrowsAsync(async () => await cache.RemoveAsync(string.Empty)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/ExceptionHandlingTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/ExceptionHandlingTests.cs index 6e438aa..c043bed 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/ExceptionHandlingTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/ExceptionHandlingTests.cs @@ -76,4 +76,4 @@ public async Task Read_From_Missing_Sproc_Wraps_SqlException() Assert.IsType(ex.InnerException); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/MultiResultSetTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/MultiResultSetTests.cs index 1733d7a..ad80f48 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/MultiResultSetTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/MultiResultSetTests.cs @@ -167,4 +167,4 @@ public async Task QueryMultipleImmutableArray_Empty_Tail_Returns_Empty_Array() Assert.Single(counts); Assert.Equal(0L, counts[0].Total); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/ReadCommandsTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/ReadCommandsTests.cs index 8e94e71..8ed97a3 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/ReadCommandsTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/ReadCommandsTests.cs @@ -144,4 +144,4 @@ public async Task ConcurrentReads_AllSucceed() Assert.Equal("W008", widgets[^1].Name); }); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/StoredProcedureTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/StoredProcedureTests.cs index a9779f7..ae5420e 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/StoredProcedureTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/StoredProcedureTests.cs @@ -141,4 +141,4 @@ public async Task ExecuteNonQueryAsync_Deletes_Row() Assert.Null(widget); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/TransactionTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/TransactionTests.cs index f4bb64f..cd5b301 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/TransactionTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/TransactionTests.cs @@ -279,4 +279,4 @@ private async Task> ListWidgetsAsync() var list = new StoredProcedureParametersBuilder("dbo", "usp_ListWidgets").Build(); return await db.QueryAsImmutableArrayAsync(list); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.IntegrationTests/Tests/TvpRoundtripTests.cs b/Tests/CaeriusNet.IntegrationTests/Tests/TvpRoundtripTests.cs index 88c17df..7e2228b 100644 --- a/Tests/CaeriusNet.IntegrationTests/Tests/TvpRoundtripTests.cs +++ b/Tests/CaeriusNet.IntegrationTests/Tests/TvpRoundtripTests.cs @@ -113,4 +113,4 @@ public async Task BulkInsert_Via_Tvp_In_Transaction_Rollback_Discards() Assert.Empty(widgets); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Builders/CaeriusNetBuilderTests.cs b/Tests/CaeriusNet.Tests/Builders/CaeriusNetBuilderTests.cs index 6fcb01f..f903fcd 100644 --- a/Tests/CaeriusNet.Tests/Builders/CaeriusNetBuilderTests.cs +++ b/Tests/CaeriusNet.Tests/Builders/CaeriusNetBuilderTests.cs @@ -143,4 +143,4 @@ public void WithSqlServer_And_WithRedis_Build_RegistersServices_WithoutThrowing( Assert.Null(exception); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs index 66180ea..884170e 100644 --- a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs +++ b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTelemetryTests.cs @@ -59,4 +59,4 @@ public void AddTvpParameter_Multiple_EmitsAllStructuredParameters() Assert.Equal(2, structured.Count); Assert.All(structured, p => Assert.Equal(TestTvpItem.TvpTypeName, p.TypeName)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTests.cs b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTests.cs index 120c42a..9694743 100644 --- a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTests.cs +++ b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTests.cs @@ -366,4 +366,4 @@ public void Constructor_EmptyProcedureName_Throws() Assert.Throws(() => builder.Build()); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs index f99150b..b72e23b 100644 --- a/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs +++ b/Tests/CaeriusNet.Tests/Builders/StoredProcedureParametersBuilderTypedTests.cs @@ -56,7 +56,7 @@ public void Build_With_Bound_Generated_Scalar_Parameters_Returns_Typed_Parameter public void Create_With_Generated_Parameters_Binds_In_One_Call() { var sp = StoredProcedureParametersBuilder - .Create(new TestScalarParameters(42), resultSetCapacity: 16) + .Create(new TestScalarParameters(42), 16) .Build(); var parameters = sp.GetParametersSpan(); diff --git a/Tests/CaeriusNet.Tests/Caches/CaeriusNetCacheInvalidationTests.cs b/Tests/CaeriusNet.Tests/Caches/CaeriusNetCacheInvalidationTests.cs index 6ccb92d..32c6f9b 100644 --- a/Tests/CaeriusNet.Tests/Caches/CaeriusNetCacheInvalidationTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/CaeriusNetCacheInvalidationTests.cs @@ -124,4 +124,4 @@ public async Task Cache_RemoveAsync_UnknownTierEnum_Throws() await Assert.ThrowsAsync(() => facade.RemoveAsync("k", (CacheType)42).AsTask()); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs b/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs index 1ded16d..53ed49c 100644 --- a/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/FrozenCacheManagerTests.cs @@ -134,4 +134,4 @@ public void StoreRange_SingleEntry_CanBeRetrieved() Assert.Equal(777, value); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Caches/FrozenCacheStateCollection.cs b/Tests/CaeriusNet.Tests/Caches/FrozenCacheStateCollection.cs index 48b1867..619e116 100644 --- a/Tests/CaeriusNet.Tests/Caches/FrozenCacheStateCollection.cs +++ b/Tests/CaeriusNet.Tests/Caches/FrozenCacheStateCollection.cs @@ -11,4 +11,4 @@ public sealed class FrozenCacheStateCollection { /// Collection name referenced by . public const string Name = "FrozenCacheState"; -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Caches/InMemoryCacheManagerTests.cs b/Tests/CaeriusNet.Tests/Caches/InMemoryCacheManagerTests.cs index 3225a20..67b9922 100644 --- a/Tests/CaeriusNet.Tests/Caches/InMemoryCacheManagerTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/InMemoryCacheManagerTests.cs @@ -98,4 +98,4 @@ public void Store_ListValue_ThenTryGet_Returns_SameList() Assert.Equal(list, value); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs b/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs index ce3cfcd..03241dc 100644 --- a/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs +++ b/Tests/CaeriusNet.Tests/Caches/RedisCacheManagerTests.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging.Abstractions; -using System.Text.Json.Serialization; namespace CaeriusNet.Tests.Caches; diff --git a/Tests/CaeriusNet.Tests/Exceptions/CaeriusNetSqlExceptionTests.cs b/Tests/CaeriusNet.Tests/Exceptions/CaeriusNetSqlExceptionTests.cs index ede72a0..84a03cd 100644 --- a/Tests/CaeriusNet.Tests/Exceptions/CaeriusNetSqlExceptionTests.cs +++ b/Tests/CaeriusNet.Tests/Exceptions/CaeriusNetSqlExceptionTests.cs @@ -54,4 +54,4 @@ public void CaeriusNetSqlException_SecondParameterType_IsAssignableTo_Exception( // Verifies the constructor contract: SqlException satisfies base Exception(string, Exception) Assert.True(typeof(Exception).IsAssignableFrom(secondParamType)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Factories/CaeriusNetDbContextTests.cs b/Tests/CaeriusNet.Tests/Factories/CaeriusNetDbContextTests.cs index 9024412..853c6d6 100644 --- a/Tests/CaeriusNet.Tests/Factories/CaeriusNetDbContextTests.cs +++ b/Tests/CaeriusNet.Tests/Factories/CaeriusNetDbContextTests.cs @@ -44,4 +44,4 @@ public void Remove(string cacheKey) { } } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/GlobalUsings.cs b/Tests/CaeriusNet.Tests/GlobalUsings.cs index 75b1eb8..fe1537c 100644 --- a/Tests/CaeriusNet.Tests/GlobalUsings.cs +++ b/Tests/CaeriusNet.Tests/GlobalUsings.cs @@ -19,4 +19,4 @@ global using CaeriusNet.Tests.Caches; global using CaeriusNet.Tests.Helpers; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Hosting; \ No newline at end of file +global using Microsoft.Extensions.Hosting; diff --git a/Tests/CaeriusNet.Tests/Helpers/CacheHelperTests.cs b/Tests/CaeriusNet.Tests/Helpers/CacheHelperTests.cs index 595f9f9..b71be5c 100644 --- a/Tests/CaeriusNet.Tests/Helpers/CacheHelperTests.cs +++ b/Tests/CaeriusNet.Tests/Helpers/CacheHelperTests.cs @@ -102,4 +102,4 @@ public void Store_Is_NoOp_When_Cache_Not_Configured() Store(sp, "ignored"); Assert.False(TryRetrieve(sp, out _)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Helpers/EmptyCollectionsTests.cs b/Tests/CaeriusNet.Tests/Helpers/EmptyCollectionsTests.cs index 31ddcfb..e85d6c9 100644 --- a/Tests/CaeriusNet.Tests/Helpers/EmptyCollectionsTests.cs +++ b/Tests/CaeriusNet.Tests/Helpers/EmptyCollectionsTests.cs @@ -51,4 +51,4 @@ public void ReadOnlyCollection_Singleton_Is_Truly_ReadOnly() // ReadOnlyCollection is immutable by design — adding via the IList interface throws. Assert.Throws(() => ((IList)collection).Add(1)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Helpers/SqlExceptionFactory.cs b/Tests/CaeriusNet.Tests/Helpers/SqlExceptionFactory.cs index 8ca38ee..0409d8d 100644 --- a/Tests/CaeriusNet.Tests/Helpers/SqlExceptionFactory.cs +++ b/Tests/CaeriusNet.Tests/Helpers/SqlExceptionFactory.cs @@ -50,4 +50,4 @@ private static Func BuildFactory() return (SqlException)exceptionCreate.Invoke(null, [collection, "0.0.0.0"])!; }; } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Helpers/TestTvpItem.cs b/Tests/CaeriusNet.Tests/Helpers/TestTvpItem.cs index 69a7803..265b6c0 100644 --- a/Tests/CaeriusNet.Tests/Helpers/TestTvpItem.cs +++ b/Tests/CaeriusNet.Tests/Helpers/TestTvpItem.cs @@ -17,4 +17,4 @@ public IEnumerable MapAsSqlDataRecords(IEnumerable i yield return record; } } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Logging/LogMessagesSmokeTests.cs b/Tests/CaeriusNet.Tests/Logging/LogMessagesSmokeTests.cs index 32eadd2..d32eb7b 100644 --- a/Tests/CaeriusNet.Tests/Logging/LogMessagesSmokeTests.cs +++ b/Tests/CaeriusNet.Tests/Logging/LogMessagesSmokeTests.cs @@ -70,4 +70,4 @@ public void Command_Execution_Methods_DoNotThrow() Logger.LogProcedureNonQueryCompleted("dbo", "sp_Test", 8L, 3); Logger.LogCacheHitSkippingExecution("test_cache_key"); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Logging/LoggerProviderTests.cs b/Tests/CaeriusNet.Tests/Logging/LoggerProviderTests.cs index 1f46908..ed1f781 100644 --- a/Tests/CaeriusNet.Tests/Logging/LoggerProviderTests.cs +++ b/Tests/CaeriusNet.Tests/Logging/LoggerProviderTests.cs @@ -94,4 +94,4 @@ public void GetLogger_After_SetLogger_Returns_NonNull() Restore(previous); } } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Mappers/StoredProcedureParametersTests.cs b/Tests/CaeriusNet.Tests/Mappers/StoredProcedureParametersTests.cs index 244eddb..898903b 100644 --- a/Tests/CaeriusNet.Tests/Mappers/StoredProcedureParametersTests.cs +++ b/Tests/CaeriusNet.Tests/Mappers/StoredProcedureParametersTests.cs @@ -187,4 +187,4 @@ public void AddParametersTo_Tvp_Clone_Creates_Reenumerable_Value() Assert.Equal(1, enumerator1.Current.GetInt32(0)); Assert.Equal(1, enumerator2.Current.GetInt32(0)); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs index da22cfe..42fd935 100644 --- a/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusActivityExtensionsTests.cs @@ -243,4 +243,4 @@ public void RecordError_SetsErrorStatusAndAttachesException() listener.Dispose(); } } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs index 3aaa999..a08527f 100644 --- a/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusDiagnosticsMetricsTests.cs @@ -112,4 +112,4 @@ private sealed class MeasurementCapture where T : struct { public ConcurrentBag<(T value, KeyValuePair[] tags)> Items { get; } = new(); } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs b/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs index 1a45670..07c9a18 100644 --- a/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs +++ b/Tests/CaeriusNet.Tests/Telemetry/CaeriusTelemetryOptionsTests.cs @@ -141,4 +141,4 @@ public void WhenCaptureParameterValues_True_NullValueShowsNullPlaceholder() listener.Dispose(); } } -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs b/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs index f4d0f0e..ec771b0 100644 --- a/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs +++ b/Tests/CaeriusNet.Tests/Telemetry/TelemetryTestsCollection.cs @@ -11,4 +11,4 @@ public sealed class TelemetryTestsCollection { /// Collection name referenced by . public const string Name = "TelemetryTests"; -} \ No newline at end of file +} diff --git a/Tests/CaeriusNet.Tests/Transactions/CaeriusNetTransactionTests.cs b/Tests/CaeriusNet.Tests/Transactions/CaeriusNetTransactionTests.cs index d588289..a64d459 100644 --- a/Tests/CaeriusNet.Tests/Transactions/CaeriusNetTransactionTests.cs +++ b/Tests/CaeriusNet.Tests/Transactions/CaeriusNetTransactionTests.cs @@ -209,4 +209,4 @@ public ValueTask DbConnectionAsync(CancellationToken cancellation "Server=tcp:127.0.0.1,1;Database=__caerius_unit_tests__;Connect Timeout=1;Encrypt=False;TrustServerCertificate=True")); } } -} \ No newline at end of file +} diff --git a/Tests/README.md b/Tests/README.md index cf92b31..8a96bfb 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -93,25 +93,25 @@ dotnet test CaeriusNet.slnx --configuration Release ### Generator tests (`CaeriusNet.Generator.Tests`) -| Category | What is tested | -|-------------|-------------------------------------------------------------------------------------------------| -| DTO | Type mappings, nullable members, special conversions, `Half`, ordinal constants, and attributes | -| TVP | Supported SQL types, multi-column generation, nullable members, and `class` generation | -| Helpers | `TypeDetector` and `ColumnExtractor` helper logic | +| Category | What is tested | +|----------|-------------------------------------------------------------------------------------------------| +| DTO | Type mappings, nullable members, special conversions, `Half`, ordinal constants, and attributes | +| TVP | Supported SQL types, multi-column generation, nullable members, and `class` generation | +| Helpers | `TypeDetector` and `ColumnExtractor` helper logic | These tests use `CSharpSourceGeneratorTest` to compile source in memory and verify generated output. ### Analyzer tests (`CaeriusNet.Analyzer.Tests`) -| Category | What is tested | -|---------------|-----------------------------------------------------| -| Attributes | `CAERIUS001` through `CAERIUS006` target diagnostics | +| Category | What is tested | +|---------------|--------------------------------------------------------| +| Attributes | `CAERIUS001` through `CAERIUS006` target diagnostics | | AutoContracts | `CAERIUS200` through `CAERIUS210` manifest diagnostics | ### Packaging tests (`CaeriusNet.Packaging.Tests`) | Category | What is tested | -|---------------|-----------------------------------------------------------------------| +|---------------|----------------------------------------------------------------------| | AutoContracts | NuGet `buildTransitive`, `AdditionalFiles`, and non-versioned naming | ### Integration tests (`CaeriusNet.IntegrationTests`) diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs b/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs index 4172b78..58c2c5d 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/CSharpName.cs @@ -28,4 +28,4 @@ internal static string FromSqlName(string name) return sb.ToString(); } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs b/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs index 6270039..97bf212 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/ConnectionStringResolver.cs @@ -63,13 +63,13 @@ private static IConfigurationRoot BuildConfiguration(CommandLineOptions options) var environment = ResolveEnvironment(options.ConfigurationEnvironment); var builder = new ConfigurationBuilder() .SetBasePath(basePath) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + .AddJsonFile("appsettings.json", true, false); if (!string.IsNullOrWhiteSpace(environment)) - builder.AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false); + builder.AddJsonFile($"appsettings.{environment}.json", true, false); if (!string.IsNullOrWhiteSpace(options.UserSecretsId)) - builder.AddJsonFile(GetUserSecretsPath(options.UserSecretsId), optional: true, reloadOnChange: false); + builder.AddJsonFile(GetUserSecretsPath(options.UserSecretsId), true, false); builder.AddEnvironmentVariables(); diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs b/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs index bbe8d39..36e2100 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/ContractDiagnostics.cs @@ -41,4 +41,4 @@ internal static void Write(ContractDiagnosticSink sink) Console.Error.WriteLine($"{diagnostic.Id} {severity}: {diagnostic.Message}"); } } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs b/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs index b194a7f..f7dbcc6 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/ContractHasher.cs @@ -102,4 +102,4 @@ private static void AppendValue(StringBuilder builder, bool value) { AppendValue(builder, value ? "true" : "false"); } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs b/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs index ee135fd..b9127fe 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/Contracts.cs @@ -61,4 +61,4 @@ internal sealed record MutableTableType(string Schema, string Name, int UserType public List Columns { get; } = []; } -internal sealed record ProcedureHeader(int ObjectId, string Schema, string Name); \ No newline at end of file +internal sealed record ProcedureHeader(int ObjectId, string Schema, string Name); diff --git a/Tools/CaeriusNet.SqlServer.Contracts/Program.cs b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs index 4a4dcd3..da77aef 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/Program.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/Program.cs @@ -119,13 +119,11 @@ private static void ReportVerifyDiagnostics(ContractManifest existing, ContractM } foreach (var procedureName in existingProcedures.Keys) - { if (!discoveredProcedures.ContainsKey(procedureName)) { emittedHashMismatch = true; Console.Error.WriteLine($"CAERIUS209 error: Contract hash mismatch for '{procedureName}'."); } - } if (!emittedHashMismatch) Console.Error.WriteLine("CAERIUS209 error: Contract hash mismatch for 'caerius.contracts.json'."); diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs index 9caf560..d97814a 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeMapper.cs @@ -56,4 +56,4 @@ private static string Normalize(string sqlType) return span.ToString().ToLowerInvariant(); } -} \ No newline at end of file +} diff --git a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs index 2fbe118..08720bd 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs +++ b/Tools/CaeriusNet.SqlServer.Contracts/SqlServerTypeName.cs @@ -40,4 +40,4 @@ private static string Normalize(string sqlType) return span.ToString().ToLowerInvariant(); } -} \ No newline at end of file +} From 493de89ee4206cd5b8dacb2fb2197b65a764e0b5 Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 19:59:34 +0200 Subject: [PATCH 7/8] fix(ci): pack autocontracts tool on linux --- .../AutoContractsPackagingTests.cs | 1 + .../CaeriusNet.SqlServer.Contracts.csproj | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs index 692841c..534bc2e 100644 --- a/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs +++ b/Tests/CaeriusNet.Packaging.Tests/AutoContractsPackagingTests.cs @@ -33,6 +33,7 @@ public async Task PackageContainsBuildTransitiveImports() var package = Assert.Single(Directory.EnumerateFiles(packageOutput, "CaeriusNet.SqlServer.Contracts.*.nupkg")); await using var archive = await ZipFile.OpenReadAsync(package); + Assert.DoesNotContain(archive.Entries, entry => entry.FullName.Contains('\\', StringComparison.Ordinal)); Assert.NotNull(archive.GetEntry("buildTransitive/CaeriusNet.SqlServer.Contracts.props")); Assert.NotNull(archive.GetEntry("buildTransitive/CaeriusNet.SqlServer.Contracts.targets")); Assert.NotNull(archive.GetEntry("tools/net10.0/any/CaeriusNet.SqlServer.Contracts.dll")); diff --git a/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj index 213814d..cf9b414 100644 --- a/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj +++ b/Tools/CaeriusNet.SqlServer.Contracts/CaeriusNet.SqlServer.Contracts.csproj @@ -44,9 +44,9 @@ - + From 096e5e86689b702aaa569221a9fbcd174a8451b7 Mon Sep 17 00:00:00 2001 From: Arius Date: Thu, 14 May 2026 20:00:58 +0200 Subject: [PATCH 8/8] fix(ci): update dependency review action to v5 --- .github/workflows/security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b6a3b7d..ed377a8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@v4 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 with: fail-on-severity: high deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0