diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index aea4075e9..c2fffb297 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -39,3 +39,9 @@ This keeps generated read models compact while still making important related va Generators no longer have to assume that every relevant type lives in the same project as the active startup target. When your solution is split across multiple projects, Coalesce can merge discovered types across those project boundaries so generated DTOs and read shapes still resolve the right sources. + +## Generated contract shapes let you declare DTO outputs from the source type + +Generated contract shapes give you a way to describe class or interface outputs directly from the source model without hand-writing every generated contract by hand. + +That makes it practical to define response-shape contracts, transport-specific interfaces, and other generated DTO surfaces while keeping the source of truth close to the domain type. diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/Models.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/Models.cs index b776a6525..5b7a3ca99 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/Models.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/Models.cs @@ -24,5 +24,8 @@ public override IEnumerable GetGenerators() .AppendOutputPath($"Generated/{model.ClientTypeName}Dto.g.cs"); } + + yield return Generator() + .WithModel(Model); } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/IntelliTect.Coalesce.CodeGeneration.Api.csproj b/src/IntelliTect.Coalesce.CodeGeneration.Api/IntelliTect.Coalesce.CodeGeneration.Api.csproj index 2896822c6..14c775348 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/IntelliTect.Coalesce.CodeGeneration.Api.csproj +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/IntelliTect.Coalesce.CodeGeneration.Api.csproj @@ -6,8 +6,14 @@ Generates ASP.NET Core API controllers, DTOs, and Semantic Kernel plugins for Coalesce projects. Learn more at https://coalesce.intellitect.com/ + + + + + - \ No newline at end of file + diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs new file mode 100644 index 000000000..e3948950e --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs @@ -0,0 +1,633 @@ +#nullable enable + +using IntelliTect.Coalesce.CodeGeneration.Analysis.Base; +using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; +using IntelliTect.Coalesce.CodeGeneration.Generation; +using IntelliTect.Coalesce.TypeDefinition; +using IntelliTect.Coalesce.Utilities; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; + +namespace IntelliTect.Coalesce.CodeGeneration.Api.Generators; + +public class GeneratedContracts : CompositeGenerator +{ + private const string ShapeAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractShapeAttribute"; + private const string AliasAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractAliasAttribute"; + 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"; + + public GeneratedContracts(CompositeGeneratorServices services) : base(services) + { + } + + private GenerationContext GenerationContext => Services.GenerationContext; + + public override IEnumerable GetGenerators() + { + var discoveredTypes = GetDiscoveredTypes(); + if (discoveredTypes.Count == 0) + { + yield break; + } + + var projectLookup = BuildProjectDirectoryLookup(GenerationContext); + var emittedTypes = new HashSet(StringComparer.Ordinal); + + foreach (var sourceType in discoveredTypes) + { + foreach (var shape in GetShapes(sourceType)) + { + var projectDirectory = ResolveProjectDirectory(projectLookup, shape, sourceType); + var typeKey = $"{shape.TargetNamespace}.{shape.TypeName}"; + if (!emittedTypes.Add(typeKey)) + { + throw new InvalidOperationException( + $"Generated contract '{typeKey}' is declared more than once. " + + $"The duplicate declaration was found on '{sourceType.ToDisplayString()}'."); + } + + yield return Generator() + .WithModel(new GeneratedContractFileModel( + shape, + ResolveProperties(sourceType, shape), + sourceType.ToDisplayString())) + .WithOutputPath(Path.Combine(projectDirectory, GeneratedContractsRelativePath, $"{shape.TypeName}.g.cs")); + } + } + } + + public override IEnumerable GetCleaners() + { + foreach (var projectDirectory in BuildProjectDirectoryLookup(GenerationContext).Values.Distinct(StringComparer.OrdinalIgnoreCase)) + { + yield return Cleaner() + .WithDepth(SearchOption.AllDirectories) + .Configure(Path.Combine(projectDirectory, GeneratedContractsRelativePath)); + } + } + + private IReadOnlyList GetDiscoveredTypes() + { + var discoveryProjects = new[] { GenerationContext.DataProject, GenerationContext.WebProject } + .OfType() + .GroupBy(project => project.ProjectFilePath, StringComparer.OrdinalIgnoreCase) + .Select(group => (Project: group.First(), Locator: (RoslynTypeLocator)group.First().TypeLocator)) + .ToList(); + + if (discoveryProjects.Count == 0) + { + return []; + } + + var mergedTypes = new Dictionary(StringComparer.Ordinal); + foreach (var (_, locator) in discoveryProjects) + { + foreach (var type in locator.GetAllTypes(includeReferencedAssemblies: true)) + { + var key = type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat); + mergedTypes.TryAdd(key, type); + } + } + + var masterLocator = discoveryProjects[^1].Locator; + + return mergedTypes.Values + .Select(type => + { + var key = type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat); + return masterLocator.FindTypeByMetadataName(key) ?? type; + }) + .Where(type => type.GetAttributes().Any(IsShapeAttribute)) + .ToList(); + } + + private static IReadOnlyDictionary BuildProjectDirectoryLookup(GenerationContext generationContext) + { + var projectLookup = new Dictionary(StringComparer.Ordinal); + var visitedProjectFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + + AddProject(generationContext.DataProject); + AddProject(generationContext.WebProject); + return projectLookup; + + void AddProject(ProjectContext? projectContext) + { + if (projectContext?.ProjectFilePath is not { Length: > 0 } projectFilePath) + { + return; + } + + ParseProject(projectFilePath); + } + + void ParseProject(string projectFilePath) + { + var fullPath = Path.GetFullPath(projectFilePath); + if (!visitedProjectFiles.Add(fullPath) || !File.Exists(fullPath)) + { + return; + } + + var projectDirectory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException($"Unable to determine project directory for '{fullPath}'."); + + var projectDocument = XDocument.Load(fullPath); + var assemblyName = projectDocument + .Descendants() + .FirstOrDefault(element => element.Name.LocalName == "AssemblyName") + ?.Value + ?.Trim(); + + if (string.IsNullOrWhiteSpace(assemblyName)) + { + assemblyName = Path.GetFileNameWithoutExtension(fullPath); + } + + projectLookup[assemblyName] = projectDirectory; + + foreach (var projectReference in projectDocument + .Descendants() + .Where(element => element.Name.LocalName == "ProjectReference") + .Select(element => element.Attribute("Include")?.Value) + .Where(include => !string.IsNullOrWhiteSpace(include))) + { + var normalizedReference = projectReference! + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + ParseProject(Path.Combine(projectDirectory, normalizedReference)); + } + } + } + + private static string ResolveProjectDirectory( + IReadOnlyDictionary projectLookup, + ContractShape shape, + INamedTypeSymbol sourceType) + { + if (projectLookup.TryGetValue(shape.TargetAssemblyName, out var projectDirectory)) + { + return projectDirectory; + } + + throw new InvalidOperationException( + $"Unable to resolve generated contract target assembly '{shape.TargetAssemblyName}' " + + $"for shape '{shape.TypeName}' on '{sourceType.ToDisplayString()}'."); + } + + private static IEnumerable GetShapes(INamedTypeSymbol sourceType) + => sourceType.GetAttributes() + .Where(IsShapeAttribute) + .Select(ParseShape) + .Where(shape => shape is not null) + .Cast(); + + private static bool IsShapeAttribute(AttributeData attribute) + => attribute.AttributeClass?.ToDisplayString() == ShapeAttributeMetadataName; + + private static ContractShape? ParseShape(AttributeData attribute) + { + if (attribute.ConstructorArguments.Length < 5) + { + return null; + } + + return new ContractShape( + (string?)attribute.ConstructorArguments[0].Value ?? string.Empty, + Convert.ToInt32(attribute.ConstructorArguments[1].Value), + (string?)attribute.ConstructorArguments[2].Value ?? string.Empty, + (string?)attribute.ConstructorArguments[3].Value ?? string.Empty, + (string?)attribute.ConstructorArguments[4].Value ?? string.Empty, + GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Policy)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Members)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.ExcludedMembers)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Implements)), + GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties))); + } + + private static int GetInt(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is not null) + { + return Convert.ToInt32(argument.Value.Value); + } + } + + return 0; + } + + private static IReadOnlyList GetStringArray(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key != name || argument.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + return argument.Value.Values + .Where(value => value.Value is string) + .Select(value => (string)value.Value!) + .ToArray(); + } + + return []; + } + + private static bool GetBool(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is bool value) + { + return value; + } + } + + return false; + } + + private static IReadOnlyList ResolveProperties(INamedTypeSymbol sourceType, ContractShape shape) + { + var memberNames = ResolveMembers(sourceType, shape); + if (memberNames.Count == 0) + { + throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' must declare at least one member."); + } + + var properties = new List(memberNames.Count); + foreach (var memberName in memberNames) + { + var property = FindProperty(sourceType, memberName) + ?? throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' references missing property '{memberName}'."); + + properties.Add(new ContractPropertyModel( + GetAlias(property, shape.ShapeName) ?? property.Name, + property, + HasAttribute(property, NullableAttributeMetadataName, shape.ShapeName), + HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName))); + } + + return properties; + } + + private static IReadOnlyList ResolveMembers(INamedTypeSymbol sourceType, ContractShape shape) + { + if (shape.Members.Count > 0) + { + return shape.Members; + } + + if (shape.Policy == PublicScalarPropertiesPolicy) + { + var excludedMembers = new HashSet(shape.ExcludedMembers, StringComparer.Ordinal); + return EnumeratePolicyProperties(sourceType) + .Where(property => !excludedMembers.Contains(property.Name)) + .Select(property => property.Name) + .ToArray(); + } + + if (shape.Policy == ExplicitPolicy) + { + return []; + } + + throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' uses unknown policy value '{shape.Policy}'."); + } + + private static IEnumerable EnumeratePolicyProperties(INamedTypeSymbol sourceType) + { + var seen = new HashSet(StringComparer.Ordinal); + + for (var current = sourceType; current is not null; current = current.BaseType) + { + foreach (var property in current.GetMembers().OfType()) + { + if (!seen.Add(property.Name) || !IsPolicyProperty(property)) + { + continue; + } + + yield return property; + } + } + } + + private static bool IsPolicyProperty(IPropertySymbol property) + => !property.IsStatic + && property.Parameters.Length == 0 + && property.DeclaredAccessibility == Accessibility.Public + && property.GetMethod?.DeclaredAccessibility == Accessibility.Public + && property.SetMethod?.DeclaredAccessibility == Accessibility.Public + && IsScalarLikeType(property.Type); + + private static IPropertySymbol? FindProperty(INamedTypeSymbol type, string memberName) + { + for (var current = type; current is not null; current = current.BaseType) + { + var property = current.GetMembers(memberName).OfType().FirstOrDefault(prop => + !prop.IsStatic && + prop.DeclaredAccessibility == Accessibility.Public && + prop.Parameters.Length == 0); + + if (property is not null) + { + return property; + } + } + + return null; + } + + private static string? GetAlias(IPropertySymbol property, string shapeName) + => property.GetAttributes() + .Where(attribute => attribute.AttributeClass?.ToDisplayString() == AliasAttributeMetadataName) + .Where(attribute => string.Equals((string?)attribute.ConstructorArguments[0].Value, shapeName, StringComparison.Ordinal)) + .Select(attribute => (string?)attribute.ConstructorArguments[1].Value) + .FirstOrDefault(alias => !string.IsNullOrWhiteSpace(alias)); + + 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 IsScalarLikeType(ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Enum) + { + return true; + } + + if (type.SpecialType is + SpecialType.System_Boolean or + SpecialType.System_Byte or + SpecialType.System_Char or + SpecialType.System_Decimal or + SpecialType.System_Double or + SpecialType.System_Int16 or + SpecialType.System_Int32 or + SpecialType.System_Int64 or + SpecialType.System_SByte or + SpecialType.System_Single or + SpecialType.System_String or + SpecialType.System_UInt16 or + SpecialType.System_UInt32 or + SpecialType.System_UInt64) + { + return true; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + named.TypeArguments.Length == 1) + { + return IsScalarLikeType(named.TypeArguments[0]); + } + + if (type is IArrayTypeSymbol arrayType) + { + return IsScalarLikeType(arrayType.ElementType); + } + + if (TryGetCollectionElementType(type, out var elementType)) + { + return IsScalarLikeType(elementType); + } + + var fullyQualifiedName = type.ToDisplayString(); + return fullyQualifiedName is + "System.Guid" or + "System.DateTime" or + "System.DateTimeOffset" or + "System.TimeSpan" or + "System.DateOnly" or + "System.TimeOnly" or + "System.Text.Json.JsonElement"; + } + + private static bool TryGetCollectionElementType(ITypeSymbol type, out ITypeSymbol elementType) + { + elementType = null!; + + if (type.SpecialType == SpecialType.System_String) + { + return false; + } + + if (type is IArrayTypeSymbol arrayType) + { + elementType = arrayType.ElementType; + return true; + } + + var enumerableInterface = type.AllInterfaces.FirstOrDefault(interfaceType => + interfaceType.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + + if (enumerableInterface?.TypeArguments.Length == 1) + { + elementType = enumerableInterface.TypeArguments[0]; + return true; + } + + return false; + } + + private static class GeneratedContractShapeAttributePlaceholder + { + public static int Policy { get; set; } + public static string[] Members { get; set; } = []; + public static string[] ExcludedMembers { get; set; } = []; + public static string[] Implements { get; set; } = []; + public static bool SettableProperties { get; set; } + } + + internal sealed record ContractShape( + string ShapeName, + int OutputKind, + string TargetAssemblyName, + string TargetNamespace, + string TypeName, + int Policy, + IReadOnlyList Members, + IReadOnlyList ExcludedMembers, + IReadOnlyList Implements, + bool SettableProperties); + + internal sealed record GeneratedContractFileModel( + ContractShape Shape, + IReadOnlyList Properties, + string SourceTypeName); + + internal sealed record ContractPropertyModel( + string Name, + IPropertySymbol Property, + bool ForceNullable, + bool ForceNonNullable); +} + +internal sealed class GeneratedContractFile : StringBuilderCSharpGenerator +{ + private static readonly SymbolDisplayFormat TypeDisplayFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: + SymbolDisplayMiscellaneousOptions.UseSpecialTypes | + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + + public GeneratedContractFile(GeneratorServices services) : base(services) + { + } + + public override void BuildOutput(CSharpCodeBuilder b) + { + b.Line("// "); + b.Line("#nullable enable"); + b.Line(); + 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)) + : string.Empty; + + using (b.Block($"public {declarationKind} {Model.Shape.TypeName}{implements}")) + { + foreach (var property in Model.Properties) + { + b.Line(BuildProperty(property)); + } + } + } + + private string BuildProperty(GeneratedContracts.ContractPropertyModel property) + { + var typeName = GetTypeName(property); + if (Model.Shape.OutputKind == 1) + { + var accessor = Model.Shape.SettableProperties ? "{ get; set; }" : "{ get; }"; + return $"{typeName} {property.Name} {accessor}"; + } + + var required = NeedsRequiredKeyword(property) ? "required " : string.Empty; + var initializer = HasCollectionInitializer(property) ? " = [];" : string.Empty; + return $"public {required}{typeName} {property.Name} {{ get; set; }}{initializer}"; + } + + private static string GetTypeName(GeneratedContracts.ContractPropertyModel property) + { + var display = property.Property.Type.ToDisplayString(TypeDisplayFormat); + if (property.ForceNullable) + { + return MakeNullable(display, property.Property.Type); + } + + if (property.ForceNonNullable) + { + return MakeNonNullable(display, property.Property.Type); + } + + return display; + } + + private static string MakeNullable(string display, ITypeSymbol type) + { + if (display.EndsWith("?", StringComparison.Ordinal)) + { + return display; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return display; + } + + return display + "?"; + } + + private static string MakeNonNullable(string display, ITypeSymbol type) + { + if (display.EndsWith("?", StringComparison.Ordinal)) + { + return display[..^1]; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + named.TypeArguments.Length == 1) + { + return named.TypeArguments[0].ToDisplayString(TypeDisplayFormat); + } + + return display; + } + + private static bool NeedsRequiredKeyword(GeneratedContracts.ContractPropertyModel property) + { + if (property.ForceNullable || HasCollectionInitializer(property)) + { + return false; + } + + return property.Property.Type.IsReferenceType && + property.Property.NullableAnnotation != NullableAnnotation.Annotated; + } + + private static bool HasCollectionInitializer(GeneratedContracts.ContractPropertyModel property) + { + if (property.ForceNullable) + { + return false; + } + + return IsCollectionType(property.Property.Type); + } + + private static bool IsCollectionType(ITypeSymbol type) + { + if (type.SpecialType == SpecialType.System_String) + { + return false; + } + + if (type is IArrayTypeSymbol) + { + return true; + } + + return type.AllInterfaces.Any(interfaceType => + interfaceType.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + } + + private static string NormalizeTypeName(string typeName) + => typeName.StartsWith("global::", StringComparison.Ordinal) + ? typeName + : $"global::{typeName}"; +} + +internal static class DirectoryCleanerExtensions +{ + public static DirectoryCleaner Configure(this DirectoryCleaner cleaner, string targetPath) + { + cleaner.TargetPath = targetPath; + return cleaner; + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj b/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj index c33cb7f76..95cf0e7db 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj +++ b/src/IntelliTect.Coalesce.CodeGeneration/IntelliTect.Coalesce.CodeGeneration.csproj @@ -6,7 +6,12 @@ Core code generation engine and services for Coalesce. Learn more at https://coalesce.intellitect.com/ - + + + diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractAliasAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractAliasAttribute.cs new file mode 100644 index 000000000..58900c19b --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractAliasAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)] +public sealed class GeneratedContractAliasAttribute : Attribute +{ + public GeneratedContractAliasAttribute(string shapeName, string alias) + { + ShapeName = shapeName; + Alias = alias; + } + + public string ShapeName { get; } + public string Alias { get; } +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNonNullableAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNonNullableAttribute.cs new file mode 100644 index 000000000..90594293c --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNonNullableAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)] +public sealed class GeneratedContractNonNullableAttribute : Attribute +{ + public GeneratedContractNonNullableAttribute(string shapeName) + { + ShapeName = shapeName; + } + + public string ShapeName { get; } +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullableAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullableAttribute.cs new file mode 100644 index 000000000..0319c4a25 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractNullableAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)] +public sealed class GeneratedContractNullableAttribute : Attribute +{ + public GeneratedContractNullableAttribute(string shapeName) + { + ShapeName = shapeName; + } + + public string ShapeName { get; } +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractOutputKind.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractOutputKind.cs new file mode 100644 index 000000000..05853d320 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractOutputKind.cs @@ -0,0 +1,7 @@ +namespace IntelliTect.Coalesce.DataAnnotations; + +public enum GeneratedContractOutputKind +{ + Class = 0, + Interface = 1, +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractPolicy.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractPolicy.cs new file mode 100644 index 000000000..61edc3f10 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractPolicy.cs @@ -0,0 +1,7 @@ +namespace IntelliTect.Coalesce.DataAnnotations; + +public enum GeneratedContractPolicy +{ + Explicit = 0, + PublicScalarProperties = 1, +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs new file mode 100644 index 000000000..498791fec --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs @@ -0,0 +1,32 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class GeneratedContractShapeAttribute : Attribute +{ + public GeneratedContractShapeAttribute( + string shapeName, + GeneratedContractOutputKind outputKind, + string targetAssemblyName, + string targetNamespace, + string typeName) + { + ShapeName = shapeName; + OutputKind = outputKind; + TargetAssemblyName = targetAssemblyName; + TargetNamespace = targetNamespace; + TypeName = typeName; + } + + public string ShapeName { get; } + public GeneratedContractOutputKind OutputKind { get; } + public string TargetAssemblyName { get; } + public string TargetNamespace { get; } + public string TypeName { get; } + public GeneratedContractPolicy Policy { get; init; } + public string[] Members { get; init; } = []; + public string[] ExcludedMembers { get; init; } = []; + public string[] Implements { get; init; } = []; + public bool SettableProperties { get; init; } +}