diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index 136d79fd5..159c6190d 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -57,3 +57,9 @@ That keeps the generated-shape pipeline usable in larger solutions where multipl Coalesce can now generate auto-projected read shapes for scenarios where the response model is intentionally smaller or differently organized than the backing entity. This is the layer that makes richer read DTOs practical without forcing every read endpoint to rely on hand-written projection types. + +## Generated contract shapes can transform nullability per output + +Generated contract shapes are not limited to a single nullability policy. A single source type can now describe stricter read-facing contracts and more permissive write- or patch-facing contracts from the same model. + +That keeps generated interfaces and DTO classes aligned with their actual API semantics instead of forcing one nullability story onto every generated output. diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs new file mode 100644 index 000000000..1259d53f4 --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs @@ -0,0 +1,124 @@ +using IntelliTect.Coalesce.CodeGeneration.Api.Generators; +using IntelliTect.Coalesce.CodeGeneration.Generation; +using IntelliTect.Coalesce.Testing; +using IntelliTect.Coalesce.Testing.Util; +using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace IntelliTect.Coalesce.CodeGeneration.Tests; + +public class GeneratedContractShapeGenerationTests : CodeGenTestBase +{ + [Test] + public async Task GeneratedContracts_SupportShapeLevelNullabilityTransforms() + { + var executor = BuildExecutor(); + var generatorServices = executor.ServiceProvider.GetRequiredService(); + + var compilation = ReflectionRepositoryFactory.GetCompilation(ReflectionRepositoryFactory.ModelSyntaxTrees); + var sourceType = compilation.GetTypeByMetadataName( + "IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext.GeneratedContractShapeEntity"); + await Assert.That(sourceType).IsNotNull(); + var shapeSourceType = sourceType!; + + var getShapes = typeof(GeneratedContracts) + .GetMethod("GetShapes", BindingFlags.NonPublic | BindingFlags.Static)!; + var resolveProperties = typeof(GeneratedContracts) + .GetMethod("ResolveProperties", BindingFlags.NonPublic | BindingFlags.Static)!; + + var shapes = ((IEnumerable)getShapes.Invoke(null, [shapeSourceType])!) + .ToDictionary(shape => shape.TypeName, StringComparer.Ordinal); + + async Task RenderShape(string typeName) + { + var shape = shapes[typeName]; + var properties = (IReadOnlyList)resolveProperties.Invoke(null, [shapeSourceType, shape])!; + var file = new GeneratedContractFile(generatorServices) + { + Model = new GeneratedContracts.GeneratedContractFileModel( + shape, + properties, + shapeSourceType.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)), + }; + + return await file.BuildOutputAsync(); + } + + var detailsContents = await RenderShape("IGeneratedContractShapeDetails"); + var writeBaseContents = await RenderShape("IGeneratedContractShapeWriteBase"); + var createContents = await RenderShape("GeneratedContractShapeCreate"); + var patchContents = await RenderShape("IGeneratedContractShapePatch"); + + await Assert.That(detailsContents.Contains("string Description { get; }")).IsTrue(); + await Assert.That(detailsContents.Contains("string Name { get; }")).IsTrue(); + + await Assert.That(writeBaseContents.Contains("string Name { get; }")).IsTrue(); + await Assert.That(writeBaseContents.Contains("string? Description { get; }")).IsTrue(); + await Assert.That(writeBaseContents.Contains("string? OptionalNotes { get; }")).IsTrue(); + await Assert.That(writeBaseContents.Contains("global::System.Collections.Generic.List? Tags { get; }")).IsTrue(); + await Assert.That(writeBaseContents.Contains("int Count { get; }")).IsTrue(); + + await Assert.That(createContents.Contains("public required string Name { get; set; }")).IsTrue(); + await Assert.That(createContents.Contains("public string? Description { get; set; }")).IsTrue(); + await Assert.That(createContents.Contains("public global::System.Collections.Generic.List? Tags { get; set; }")).IsTrue(); + + await Assert.That(patchContents.Contains("string? Name { get; }")).IsTrue(); + await Assert.That(patchContents.Contains("int? Count { get; }")).IsTrue(); + await Assert.That(patchContents.Contains("bool? Enabled { get; }")).IsTrue(); + await Assert.That(patchContents.Contains("global::System.DateTime? EffectiveOn { get; }")).IsTrue(); + + ReflectionRepositoryFactory.GetCompilation( + [ + CSharpSyntaxTree.ParseText(SourceText.From(detailsContents), path: "IGeneratedContractShapeDetails.g.cs"), + CSharpSyntaxTree.ParseText(SourceText.From(writeBaseContents), path: "IGeneratedContractShapeWriteBase.g.cs"), + CSharpSyntaxTree.ParseText(SourceText.From(createContents), path: "GeneratedContractShapeCreate.g.cs"), + CSharpSyntaxTree.ParseText(SourceText.From(patchContents), path: "IGeneratedContractShapePatch.g.cs"), + ], + assertSuccess: true); + } + + [Test] + public async Task GeneratedContracts_PropagateDtoSourceAttributesToClassOutputs() + { + var executor = BuildExecutor(); + var generatorServices = executor.ServiceProvider.GetRequiredService(); + + var compilation = ReflectionRepositoryFactory.GetCompilation(ReflectionRepositoryFactory.ModelSyntaxTrees); + var sourceType = compilation.GetTypeByMetadataName( + "IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext.GeneratedContractShapeProjectionSource"); + await Assert.That(sourceType).IsNotNull(); + var shapeSourceType = sourceType!; + + var getShapes = typeof(GeneratedContracts) + .GetMethod("GetShapes", BindingFlags.NonPublic | BindingFlags.Static)!; + var resolveProperties = typeof(GeneratedContracts) + .GetMethod("ResolveProperties", BindingFlags.NonPublic | BindingFlags.Static)!; + + var shape = ((IEnumerable)getShapes.Invoke(null, [shapeSourceType])!) + .Single(candidate => candidate.TypeName == "GeneratedContractShapeProjectionSpec"); + + var properties = (IReadOnlyList)resolveProperties.Invoke(null, [shapeSourceType, shape])!; + var file = new GeneratedContractFile(generatorServices) + { + Model = new GeneratedContracts.GeneratedContractFileModel( + shape, + properties, + shapeSourceType.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)), + }; + + var contents = await file.BuildOutputAsync(); + + await Assert.That(contents.Contains("[global::IntelliTect.Coalesce.DataAnnotations.DtoSource(\"OwnerTenant.Name\")]")).IsTrue(); + await Assert.That(contents.Contains("[global::IntelliTect.Coalesce.DataAnnotations.DtoSource(\"Children\", OrderBy = \"Name\", OrderByDirection = global::IntelliTect.Coalesce.DataAnnotations.DefaultOrderByAttribute.OrderByDirections.Descending)]")).IsTrue(); + await Assert.That(contents.Contains("public string? TenantName { get; set; }")).IsTrue(); + await Assert.That(contents.Contains("public global::System.Collections.Generic.List OrderedChildren { get; set; } = [];")).IsTrue(); + + ReflectionRepositoryFactory.GetCompilation( + [CSharpSyntaxTree.ParseText(SourceText.From(contents), path: "GeneratedContractShapeProjectionSpec.g.cs")], + assertSuccess: true); + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs index e3948950e..8c520f594 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs @@ -2,10 +2,15 @@ using IntelliTect.Coalesce.CodeGeneration.Analysis.Base; using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; +using IntelliTect.Coalesce.DataAnnotations; using IntelliTect.Coalesce.CodeGeneration.Generation; using IntelliTect.Coalesce.TypeDefinition; using IntelliTect.Coalesce.Utilities; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Formatting; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Text; using System; using System.Collections.Generic; using System.IO; @@ -19,13 +24,14 @@ public class GeneratedContracts : CompositeGenerator { private const string ShapeAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractShapeAttribute"; private const string AliasAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractAliasAttribute"; + private const string DtoSourceAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.DtoSourceAttribute"; private const string NullableAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractNullableAttribute"; private const string NonNullableAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractNonNullableAttribute"; private const int ExplicitPolicy = 0; private const int PublicScalarPropertiesPolicy = 1; private const int ClassOutputKind = 0; private const int InterfaceOutputKind = 1; - private const string GeneratedContractsRelativePath = "Generated/Contracts"; + internal const string GeneratedContractsRelativePath = "Generated/Contracts"; public GeneratedContracts(CompositeGeneratorServices services) : base(services) { @@ -185,7 +191,7 @@ private static string ResolveProjectDirectory( $"for shape '{shape.TypeName}' on '{sourceType.ToDisplayString()}'."); } - private static IEnumerable GetShapes(INamedTypeSymbol sourceType) + internal static IEnumerable GetShapes(INamedTypeSymbol sourceType) => sourceType.GetAttributes() .Where(IsShapeAttribute) .Select(ParseShape) @@ -212,7 +218,8 @@ private static bool IsShapeAttribute(AttributeData attribute) GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Members)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.ExcludedMembers)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Implements)), - GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties))); + GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties)), + GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.NullabilityTransform))); } private static int GetInt(AttributeData attribute, string name) @@ -259,11 +266,29 @@ private static bool GetBool(AttributeData attribute, string name) return false; } - private static IReadOnlyList ResolveProperties(INamedTypeSymbol sourceType, ContractShape shape) + private static string? GetString(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is string value) + { + return value; + } + } + + return null; + } + + internal static IReadOnlyList ResolveProperties(INamedTypeSymbol sourceType, ContractShape shape) { var memberNames = ResolveMembers(sourceType, shape); if (memberNames.Count == 0) { + if (shape.OutputKind == InterfaceOutputKind) + { + return []; + } + throw new InvalidOperationException( $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' must declare at least one member."); } @@ -278,8 +303,9 @@ private static IReadOnlyList ResolveProperties(INamedType properties.Add(new ContractPropertyModel( GetAlias(property, shape.ShapeName) ?? property.Name, property, - HasAttribute(property, NullableAttributeMetadataName, shape.ShapeName), - HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName))); + ShouldForceNullable(property, shape), + HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName), + GetDtoSource(property))); } return properties; @@ -361,11 +387,55 @@ private static bool IsPolicyProperty(IPropertySymbol property) .Select(attribute => (string?)attribute.ConstructorArguments[1].Value) .FirstOrDefault(alias => !string.IsNullOrWhiteSpace(alias)); + private static DtoSourceMetadata? GetDtoSource(IPropertySymbol property) + { + var attribute = property.GetAttributes() + .FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == DtoSourceAttributeMetadataName); + + if (attribute is null || attribute.ConstructorArguments.Length == 0) + { + return null; + } + + var path = (string?)attribute.ConstructorArguments[0].Value; + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return new DtoSourceMetadata( + path, + GetString(attribute, nameof(DtoSourceAttribute.OrderBy)), + GetInt(attribute, nameof(DtoSourceAttribute.OrderByDirection))); + } + private static bool HasAttribute(IPropertySymbol property, string attributeMetadataName, string shapeName) => property.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == attributeMetadataName && string.Equals((string?)attribute.ConstructorArguments[0].Value, shapeName, StringComparison.Ordinal)); + private static bool ShouldForceNullable(IPropertySymbol property, ContractShape shape) + { + if (HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName)) + { + return false; + } + + if (HasAttribute(property, NullableAttributeMetadataName, shape.ShapeName)) + { + return true; + } + + var transform = (GeneratedContractNullabilityTransform)shape.NullabilityTransform; + if (transform == GeneratedContractNullabilityTransform.None) + { + return false; + } + + return (transform.HasFlag(GeneratedContractNullabilityTransform.NullableReferenceTypes) && property.Type.IsReferenceType) + || (transform.HasFlag(GeneratedContractNullabilityTransform.NullableValueTypes) && IsNonNullableValueType(property.Type)); + } + private static bool IsScalarLikeType(ITypeSymbol type) { if (type.TypeKind == TypeKind.Enum) @@ -454,6 +524,7 @@ private static class GeneratedContractShapeAttributePlaceholder public static string[] ExcludedMembers { get; set; } = []; public static string[] Implements { get; set; } = []; public static bool SettableProperties { get; set; } + public static int NullabilityTransform { get; set; } } internal sealed record ContractShape( @@ -466,7 +537,8 @@ internal sealed record ContractShape( IReadOnlyList Members, IReadOnlyList ExcludedMembers, IReadOnlyList Implements, - bool SettableProperties); + bool SettableProperties, + int NullabilityTransform); internal sealed record GeneratedContractFileModel( ContractShape Shape, @@ -477,7 +549,24 @@ internal sealed record ContractPropertyModel( string Name, IPropertySymbol Property, bool ForceNullable, - bool ForceNonNullable); + bool ForceNonNullable, + DtoSourceMetadata? DtoSource); + + internal sealed record DtoSourceMetadata( + string Path, + string? OrderBy, + int OrderByDirection); + + private static bool IsNonNullableValueType(ITypeSymbol type) + { + if (!type.IsValueType) + { + return false; + } + + return type is not INamedTypeSymbol named + || named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; + } } internal sealed class GeneratedContractFile : StringBuilderCSharpGenerator @@ -496,39 +585,108 @@ public GeneratedContractFile(GeneratorServices services) : base(services) } public override void BuildOutput(CSharpCodeBuilder b) + => BuildOutput(b, Model); + + internal static SyntaxTree CreateSyntaxTree( + GeneratedContracts.GeneratedContractFileModel model, + CSharpParseOptions? parseOptions = null, + string? path = null) + => CSharpSyntaxTree.ParseText( + SourceText.From(Render(model)), + parseOptions ?? CSharpParseOptions.Default, + path ?? string.Empty); + + internal static string Render( + GeneratedContracts.GeneratedContractFileModel model, + int indentationSize = 4) + { + var b = new CSharpCodeBuilder(); + BuildOutput(b, model); + var output = b.ToString(); + + var syntaxTree = CSharpSyntaxTree.ParseText(SourceText.From(output)); + var root = syntaxTree.GetRoot(); + + using var workspace = new AdhocWorkspace(); + var options = workspace.Options + .WithChangedOption(FormattingOptions.NewLine, LanguageNames.CSharp, Environment.NewLine) + .WithChangedOption(FormattingOptions.UseTabs, LanguageNames.CSharp, false) + .WithChangedOption(FormattingOptions.IndentationSize, LanguageNames.CSharp, indentationSize) + .WithChangedOption(FormattingOptions.SmartIndent, LanguageNames.CSharp, FormattingOptions.IndentStyle.Smart) + .WithChangedOption(CSharpFormattingOptions.WrappingKeepStatementsOnSingleLine, true); + + root = Formatter.Format(root, workspace, options); + return root.ToFullString(); + } + + private static void BuildOutput(CSharpCodeBuilder b, GeneratedContracts.GeneratedContractFileModel model) { b.Line("// "); b.Line("#nullable enable"); b.Line(); - b.Line($"namespace {Model.Shape.TargetNamespace};"); + b.Line($"namespace {model.Shape.TargetNamespace};"); b.Line(); - var declarationKind = Model.Shape.OutputKind == 1 ? "interface" : "partial class"; - var implements = Model.Shape.Implements.Count > 0 - ? " : " + string.Join(", ", Model.Shape.Implements.Select(NormalizeTypeName)) + var declarationKind = model.Shape.OutputKind == 1 ? "interface" : "partial class"; + var implements = model.Shape.Implements.Count > 0 + ? " : " + string.Join(", ", model.Shape.Implements.Select(NormalizeTypeName)) : string.Empty; - using (b.Block($"public {declarationKind} {Model.Shape.TypeName}{implements}")) + using (b.Block($"public {declarationKind} {model.Shape.TypeName}{implements}")) { - foreach (var property in Model.Properties) + foreach (var property in model.Properties) { - b.Line(BuildProperty(property)); + foreach (var line in BuildPropertyLines(model, property)) + { + b.Line(line); + } } } } - private string BuildProperty(GeneratedContracts.ContractPropertyModel property) + private static IEnumerable BuildPropertyLines( + GeneratedContracts.GeneratedContractFileModel model, + GeneratedContracts.ContractPropertyModel property) { + if (property.DtoSource is { } dtoSource) + { + yield return BuildDtoSourceAttribute(dtoSource); + } + var typeName = GetTypeName(property); - if (Model.Shape.OutputKind == 1) + if (model.Shape.OutputKind == 1) { - var accessor = Model.Shape.SettableProperties ? "{ get; set; }" : "{ get; }"; - return $"{typeName} {property.Name} {accessor}"; + var accessor = model.Shape.SettableProperties ? "{ get; set; }" : "{ get; }"; + yield return $"{typeName} {property.Name} {accessor}"; + yield break; } var required = NeedsRequiredKeyword(property) ? "required " : string.Empty; var initializer = HasCollectionInitializer(property) ? " = [];" : string.Empty; - return $"public {required}{typeName} {property.Name} {{ get; set; }}{initializer}"; + yield return $"public {required}{typeName} {property.Name} {{ get; set; }}{initializer}"; + } + + private static string BuildDtoSourceAttribute(GeneratedContracts.DtoSourceMetadata dtoSource) + { + var args = new List + { + SymbolDisplay.FormatLiteral(dtoSource.Path, quote: true) + }; + + if (!string.IsNullOrWhiteSpace(dtoSource.OrderBy)) + { + args.Add($"OrderBy = {SymbolDisplay.FormatLiteral(dtoSource.OrderBy, quote: true)}"); + } + + if (dtoSource.OrderByDirection != 0) + { + var direction = dtoSource.OrderByDirection == 1 ? "Descending" : "Ascending"; + args.Add( + "OrderByDirection = " + + $"global::IntelliTect.Coalesce.DataAnnotations.DefaultOrderByAttribute.OrderByDirections.{direction}"); + } + + return $"[global::IntelliTect.Coalesce.DataAnnotations.DtoSource({string.Join(", ", args)})]"; } private static string GetTypeName(GeneratedContracts.ContractPropertyModel property) diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs new file mode 100644 index 000000000..deba0f8f3 --- /dev/null +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs @@ -0,0 +1,78 @@ +#nullable enable + +using IntelliTect.Coalesce.DataAnnotations; +using System; +using System.Collections.Generic; + +namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; + +[GeneratedContractShape( + "details", + GeneratedContractOutputKind.Interface, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "IGeneratedContractShapeDetails", + Policy = GeneratedContractPolicy.PublicScalarProperties, + ExcludedMembers = [nameof(Id)])] +[GeneratedContractShape( + "write-base", + GeneratedContractOutputKind.Interface, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "IGeneratedContractShapeWriteBase", + Policy = GeneratedContractPolicy.PublicScalarProperties, + ExcludedMembers = [nameof(Id)], + NullabilityTransform = GeneratedContractNullabilityTransform.NullableReferenceTypes)] +[GeneratedContractShape( + "create", + GeneratedContractOutputKind.Class, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "GeneratedContractShapeCreate", + Policy = GeneratedContractPolicy.PublicScalarProperties, + ExcludedMembers = [nameof(Id)], + Implements = ["IntelliTect.Coalesce.Testing.GeneratedContracts.IGeneratedContractShapeWriteBase"], + NullabilityTransform = GeneratedContractNullabilityTransform.NullableReferenceTypes)] +[GeneratedContractShape( + "patch", + GeneratedContractOutputKind.Interface, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "IGeneratedContractShapePatch", + Policy = GeneratedContractPolicy.PublicScalarProperties, + ExcludedMembers = [nameof(Id)], + NullabilityTransform = GeneratedContractNullabilityTransform.NullableAll)] +public class GeneratedContractShapeEntity +{ + public int Id { get; set; } + + [GeneratedContractNonNullable("write-base")] + [GeneratedContractNonNullable("create")] + public string Name { get; set; } = null!; + + public string Description { get; set; } = null!; + public string? OptionalNotes { get; set; } + public int Count { get; set; } + public bool Enabled { get; set; } + public DateTime EffectiveOn { get; set; } + public List Tags { get; set; } = []; +} + +[GeneratedContractShape( + "projection-spec", + GeneratedContractOutputKind.Class, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "GeneratedContractShapeProjectionSpec", + Members = [nameof(TenantName), nameof(OrderedChildren)])] +public class GeneratedContractShapeProjectionSource +{ + [DtoSource("OwnerTenant.Name")] + public string? TenantName { get; set; } + + [DtoSource( + "Children", + OrderBy = "Name", + OrderByDirection = DefaultOrderByAttribute.OrderByDirections.Descending)] + public List OrderedChildren { get; set; } = []; +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullabilityTransform.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullabilityTransform.cs new file mode 100644 index 000000000..6188f83c8 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullabilityTransform.cs @@ -0,0 +1,12 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[Flags] +public enum GeneratedContractNullabilityTransform +{ + None = 0, + NullableReferenceTypes = 1, + NullableValueTypes = 2, + NullableAll = NullableReferenceTypes | NullableValueTypes, +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs index 498791fec..ca83b9fbf 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs @@ -29,4 +29,5 @@ public GeneratedContractShapeAttribute( public string[] ExcludedMembers { get; init; } = []; public string[] Implements { get; init; } = []; public bool SettableProperties { get; init; } + public GeneratedContractNullabilityTransform NullabilityTransform { get; init; } }