diff --git a/src/CompositeKey.Analyzers.Common.UnitTests/Validation/PropertyValidationTests.cs b/src/CompositeKey.Analyzers.Common.UnitTests/Validation/PropertyValidationTests.cs index 8e46280..ff30660 100644 --- a/src/CompositeKey.Analyzers.Common.UnitTests/Validation/PropertyValidationTests.cs +++ b/src/CompositeKey.Analyzers.Common.UnitTests/Validation/PropertyValidationTests.cs @@ -413,6 +413,139 @@ public void UnsupportedType_IsNotCompatible() } } + public class RepeatingPropertyTypeValidation + { + [Fact] + public void ListType_IsRepeatingType() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.Collections.Generic.List", + IsList: true, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Assert + repeatingTypeInfo.IsRepeatingType.ShouldBeTrue(); + } + + [Fact] + public void ReadOnlyListType_IsRepeatingType() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.Collections.Generic.IReadOnlyList", + IsList: false, + IsReadOnlyList: true, + IsImmutableArray: false); + + // Assert + repeatingTypeInfo.IsRepeatingType.ShouldBeTrue(); + } + + [Fact] + public void ImmutableArrayType_IsRepeatingType() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.Collections.Immutable.ImmutableArray", + IsList: false, + IsReadOnlyList: false, + IsImmutableArray: true); + + // Assert + repeatingTypeInfo.IsRepeatingType.ShouldBeTrue(); + } + + [Fact] + public void NonRepeatingType_IsNotRepeatingType() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.String", + IsList: false, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Assert + repeatingTypeInfo.IsRepeatingType.ShouldBeFalse(); + } + + [Fact] + public void RepeatingPropertyWithRepeatingType_ShouldReturnSuccess() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.Collections.Generic.List", + IsList: true, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Act + var result = PropertyValidation.ValidateRepeatingPropertyType("Tags", repeatingTypeInfo); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void RepeatingPropertyWithNonRepeatingType_ShouldReturnFailure() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.String", + IsList: false, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Act + var result = PropertyValidation.ValidateRepeatingPropertyType("Name", repeatingTypeInfo); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustUseCollectionType); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Name"); + } + + [Fact] + public void NonRepeatingPropertyWithRepeatingType_ShouldReturnFailure() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.Collections.Generic.List", + IsList: true, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Act + var result = PropertyValidation.ValidateNonRepeatingPropertyType("Tags", repeatingTypeInfo); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingTypeMustUseRepeatingSyntax); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Tags"); + } + + [Fact] + public void NonRepeatingPropertyWithNonRepeatingType_ShouldReturnSuccess() + { + // Arrange + var repeatingTypeInfo = new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: "System.String", + IsList: false, + IsReadOnlyList: false, + IsImmutableArray: false); + + // Act + var result = PropertyValidation.ValidateNonRepeatingPropertyType("Name", repeatingTypeInfo); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + } + public class FormattedLengthCalculation { [Theory] diff --git a/src/CompositeKey.Analyzers.Common.UnitTests/Validation/TemplateValidationTests.cs b/src/CompositeKey.Analyzers.Common.UnitTests/Validation/TemplateValidationTests.cs index 4589c78..a643042 100644 --- a/src/CompositeKey.Analyzers.Common.UnitTests/Validation/TemplateValidationTests.cs +++ b/src/CompositeKey.Analyzers.Common.UnitTests/Validation/TemplateValidationTests.cs @@ -232,6 +232,75 @@ public void NoPropertyTokens_ShouldReturnSuccess() // Assert result.IsSuccess.ShouldBeTrue(); } + + [Fact] + public void RepeatingPropertyExistsWithGetterAndSetter_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("PREFIX"), + TemplateToken.Delimiter('#'), + TemplateToken.RepeatingProperty("Tags", '#') + }; + + var availableProperties = new[] + { + new TemplateValidation.PropertyInfo("Tags", HasGetter: true, HasSetter: true) + }; + + // Act + var result = TemplateValidation.ValidatePropertyReferences(tokens, availableProperties.ToList()); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void RepeatingPropertyDoesNotExist_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.RepeatingProperty("NonExistent", '#') + }; + + var availableProperties = new[] + { + new TemplateValidation.PropertyInfo("Tags", HasGetter: true, HasSetter: true) + }; + + // Act + var result = TemplateValidation.ValidatePropertyReferences(tokens, availableProperties.ToList()); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.PropertyMustHaveAccessibleGetterAndSetter); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("NonExistent"); + } + + [Fact] + public void RepeatingPropertyWithoutSetter_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.RepeatingProperty("Tags", '#') + }; + + var availableProperties = new[] + { + new TemplateValidation.PropertyInfo("Tags", HasGetter: true, HasSetter: false) + }; + + // Act + var result = TemplateValidation.ValidatePropertyReferences(tokens, availableProperties.ToList()); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.PropertyMustHaveAccessibleGetterAndSetter); + } } public class TokenizeTemplateStringTests @@ -372,6 +441,127 @@ public void MultipleDelimiters_ShouldTokenizeCorrectly() } } + public class TokenizeRepeatingPropertyTests + { + [Fact] + public void RepeatingPropertyWithoutFormat_ShouldTokenizeCorrectly() + { + // Arrange + const string templateString = "{Prop...#}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeTrue(); + result.Tokens.Count.ShouldBe(1); + result.Tokens[0].ShouldBeOfType(); + var token = (RepeatingPropertyTemplateToken)result.Tokens[0]; + token.Name.ShouldBe("Prop"); + token.Separator.ShouldBe('#'); + token.Format.ShouldBeNull(); + } + + [Fact] + public void RepeatingPropertyWithFormat_ShouldTokenizeCorrectly() + { + // Arrange + const string templateString = "{Prop:D...#}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeTrue(); + result.Tokens.Count.ShouldBe(1); + result.Tokens[0].ShouldBeOfType(); + var token = (RepeatingPropertyTemplateToken)result.Tokens[0]; + token.Name.ShouldBe("Prop"); + token.Format.ShouldBe("D"); + token.Separator.ShouldBe('#'); + } + + [Fact] + public void RepeatingPropertyWithNoSeparator_ShouldReturnFailure() + { + // Arrange + const string templateString = "{Prop...}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeFalse(); + } + + [Fact] + public void RepeatingPropertyWithMultiCharSeparator_ShouldReturnFailure() + { + // Arrange + const string templateString = "{Prop...##}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeFalse(); + } + + [Fact] + public void RepeatingPropertyWithDigitSeparator_ShouldReturnFailure() + { + // Arrange + const string templateString = "{Prop...1}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeFalse(); + } + + [Fact] + public void RepeatingPropertyWithLetterSeparator_ShouldReturnFailure() + { + // Arrange + const string templateString = "{Prop...a}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeFalse(); + } + + [Fact] + public void RepeatingPropertyWithConstantAndDelimiter_ShouldTokenizeCorrectly() + { + // Arrange + const string templateString = "LOCATION#{Prop...#}"; + char? separator = null; + + // Act + var result = TemplateValidation.TokenizeTemplateString(templateString, separator); + + // Assert + result.Success.ShouldBeTrue(); + result.Tokens.Count.ShouldBe(3); + result.Tokens[0].ShouldBeOfType(); + ((ConstantTemplateToken)result.Tokens[0]).Value.ShouldBe("LOCATION"); + result.Tokens[1].ShouldBeOfType(); + result.Tokens[2].ShouldBeOfType(); + var token = (RepeatingPropertyTemplateToken)result.Tokens[2]; + token.Name.ShouldBe("Prop"); + token.Separator.ShouldBe('#'); + } + } + public class HasValidTemplateStructureTests { [Fact] @@ -454,6 +644,22 @@ public void EmptyTokenList_ShouldReturnFalse() // Assert result.ShouldBeFalse(); } + + [Fact] + public void TemplateWithRepeatingPropertyToken_ShouldReturnTrue() + { + // Arrange + var tokens = new List + { + TemplateToken.RepeatingProperty("Tags", '#') + }; + + // Act + var result = TemplateValidation.HasValidTemplateStructure(tokens); + + // Assert + result.ShouldBeTrue(); + } } public class ValidatePartitionAndSortKeyStructureTests @@ -555,6 +761,260 @@ public void PrimaryDelimiterWithOnlyDelimitersOnBothSides_ShouldReturnFalse() // Assert result.ShouldBeFalse(); } + + [Fact] + public void CompositeKeyWithRepeatingPropertyInSortKey_ShouldReturnTrue() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("USER"), + TemplateToken.Delimiter('#'), + TemplateToken.Property("UserId"), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.Constant("TAG"), + TemplateToken.Delimiter('#'), + TemplateToken.RepeatingProperty("Tags", '#') + }; + + // Act + var result = TemplateValidation.ValidatePartitionAndSortKeyStructure(tokens, out int primaryDelimiterIndex); + + // Assert + result.ShouldBeTrue(); + primaryDelimiterIndex.ShouldBe(3); + } + } + + public class ValidateRepeatingPropertyPositionTests + { + [Fact] + public void RepeatingPropertyAsLastPart_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("USER"), + TemplateToken.Delimiter('#'), + TemplateToken.Property("UserId"), + TemplateToken.Delimiter('#'), + TemplateToken.RepeatingProperty("Tags", '#') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void RepeatingPropertyNotLastPart_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("USER"), + TemplateToken.Delimiter('#'), + TemplateToken.RepeatingProperty("Tags", '#'), + TemplateToken.Delimiter('#'), + TemplateToken.Property("UserId") + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Tags"); + } + + [Fact] + public void NoRepeatingProperties_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("USER"), + TemplateToken.Delimiter('#'), + TemplateToken.Property("UserId") + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void CompositeKey_RepeatingPropertyLastInSortKey_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Property("UserId"), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.Constant("TAG"), + TemplateToken.Delimiter('-'), + TemplateToken.RepeatingProperty("Tags", ',') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void CompositeKey_RepeatingPropertyNotLastInPartitionKey_ShouldReturnFailure() + { + // Arrange - repeating property in partition key section, not at the end + var tokens = new List + { + TemplateToken.RepeatingProperty("Tags", ','), + TemplateToken.Delimiter('-'), + TemplateToken.Constant("SUFFIX"), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.Property("SortKey") + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Tags"); + } + + [Fact] + public void CompositeKey_RepeatingPropertyNotLastInSortKey_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.Property("UserId"), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.RepeatingProperty("Tags", ','), + TemplateToken.Delimiter('-'), + TemplateToken.Constant("SUFFIX") + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyPosition(tokens); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Tags"); + } + } + + public class ValidateRepeatingPropertyCountTests + { + [Fact] + public void SingleRepeatingProperty_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("PREFIX"), + TemplateToken.Delimiter('#'), + TemplateToken.RepeatingProperty("Tags", '#') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyCount(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void NoRepeatingProperties_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.Constant("USER"), + TemplateToken.Delimiter('#'), + TemplateToken.Property("UserId") + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyCount(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void MultipleRepeatingPropertiesInSimpleKey_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.RepeatingProperty("Tags", '#'), + TemplateToken.Delimiter('-'), + TemplateToken.RepeatingProperty("Items", ',') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyCount(tokens); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Items"); + } + + [Fact] + public void CompositeKey_OneRepeatingPerSection_ShouldReturnSuccess() + { + // Arrange + var tokens = new List + { + TemplateToken.RepeatingProperty("Tags", ','), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.RepeatingProperty("Items", ',') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyCount(tokens); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public void CompositeKey_MultipleRepeatingInSortKey_ShouldReturnFailure() + { + // Arrange + var tokens = new List + { + TemplateToken.Property("UserId"), + TemplateToken.PrimaryDelimiter('#'), + TemplateToken.RepeatingProperty("Tags", ','), + TemplateToken.Delimiter('-'), + TemplateToken.RepeatingProperty("Items", ',') + }; + + // Act + var result = TemplateValidation.ValidateRepeatingPropertyCount(tokens); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Descriptor.ShouldBe(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); + result.MessageArgs.ShouldNotBeNull(); + result.MessageArgs[0].ShouldBe("Items"); + } } public class ValidateTemplateFormatTests diff --git a/src/CompositeKey.Analyzers.Common/Diagnostics/DiagnosticDescriptors.cs b/src/CompositeKey.Analyzers.Common/Diagnostics/DiagnosticDescriptors.cs index bce3b7b..815de57 100644 --- a/src/CompositeKey.Analyzers.Common/Diagnostics/DiagnosticDescriptors.cs +++ b/src/CompositeKey.Analyzers.Common/Diagnostics/DiagnosticDescriptors.cs @@ -98,4 +98,37 @@ public static class DiagnosticDescriptors category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// COMPOSITE0009: Repeating property must use a collection type. + /// + public static DiagnosticDescriptor RepeatingPropertyMustUseCollectionType { get; } = new( + id: "COMPOSITE0009", + title: Strings.RepeatingPropertyMustUseCollectionTypeTitle, + messageFormat: Strings.RepeatingPropertyMustUseCollectionTypeMessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// COMPOSITE0010: Repeating type must use repeating syntax. + /// + public static DiagnosticDescriptor RepeatingTypeMustUseRepeatingSyntax { get; } = new( + id: "COMPOSITE0010", + title: Strings.RepeatingTypeMustUseRepeatingSyntaxTitle, + messageFormat: Strings.RepeatingTypeMustUseRepeatingSyntaxMessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// COMPOSITE0011: Repeating property must be the last part in its key section. + /// + public static DiagnosticDescriptor RepeatingPropertyMustBeLastPart { get; } = new( + id: "COMPOSITE0011", + title: Strings.RepeatingPropertyMustBeLastPartTitle, + messageFormat: Strings.RepeatingPropertyMustBeLastPartMessageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/src/CompositeKey.Analyzers.Common/Resources/Strings.resx b/src/CompositeKey.Analyzers.Common/Resources/Strings.resx index 9754b61..b5a43c0 100644 --- a/src/CompositeKey.Analyzers.Common/Resources/Strings.resx +++ b/src/CompositeKey.Analyzers.Common/Resources/Strings.resx @@ -66,4 +66,22 @@ The property '{0}' uses format '{1}' which is either invalid for the properties type or unsupported by the 'CompositeKey' source generator. + + Repeating property must use a collection type. + + + The property '{0}' uses repeating syntax but is not a supported collection type (List<T>, IReadOnlyList<T>, or ImmutableArray<T>). + + + Repeating type must use repeating syntax. + + + The property '{0}' is a repeating type but does not use repeating syntax ('...separator') in the template. + + + Repeating property must be the last part in its key section. + + + The repeating property '{0}' must be the last value part in its key section. + diff --git a/src/CompositeKey.Analyzers.Common/Tokenization/TemplateStringTokenizer.cs b/src/CompositeKey.Analyzers.Common/Tokenization/TemplateStringTokenizer.cs index 14ce225..2064b88 100644 --- a/src/CompositeKey.Analyzers.Common/Tokenization/TemplateStringTokenizer.cs +++ b/src/CompositeKey.Analyzers.Common/Tokenization/TemplateStringTokenizer.cs @@ -72,6 +72,10 @@ private static ReadResult ReadProperty(ReadOnlySpan input, var propertySpan = input.Slice(startPosition, currentPosition - startPosition); currentPosition++; + int dotsIndex = propertySpan.LastIndexOf("...".AsSpan()); + if (dotsIndex >= 0) + return ReadRepeatingProperty(propertySpan, dotsIndex); + int colonIndex = propertySpan.IndexOf(':'); var token = colonIndex != -1 ? TemplateToken.Property(propertySpan[..colonIndex].ToString(), propertySpan[(colonIndex + 1)..].ToString()) @@ -80,6 +84,26 @@ private static ReadResult ReadProperty(ReadOnlySpan input, return ReadResult.CreateSuccess(token); } + private static ReadResult ReadRepeatingProperty(ReadOnlySpan propertySpan, int dotsIndex) + { + var beforeDots = propertySpan[..dotsIndex]; + var afterDots = propertySpan[(dotsIndex + 3)..]; + + if (afterDots.Length != 1) + return ReadResult.CreateFailure(); + + char separator = afterDots[0]; + if (char.IsLetterOrDigit(separator) || separator == '{' || separator == '}') + return ReadResult.CreateFailure(); + + int colonIndex = beforeDots.IndexOf(':'); + var token = colonIndex != -1 + ? TemplateToken.RepeatingProperty(beforeDots[..colonIndex].ToString(), separator, beforeDots[(colonIndex + 1)..].ToString()) + : TemplateToken.RepeatingProperty(beforeDots.ToString(), separator); + + return ReadResult.CreateSuccess(token); + } + private static ReadResult ReadConstantValue(ReadOnlySpan input, ref int currentPosition) { int startPosition = currentPosition; diff --git a/src/CompositeKey.Analyzers.Common/Tokenization/TemplateToken.cs b/src/CompositeKey.Analyzers.Common/Tokenization/TemplateToken.cs index a0b6c6d..26757b6 100644 --- a/src/CompositeKey.Analyzers.Common/Tokenization/TemplateToken.cs +++ b/src/CompositeKey.Analyzers.Common/Tokenization/TemplateToken.cs @@ -10,12 +10,15 @@ public abstract record TemplateToken(TemplateToken.TemplateTokenType Type) public static TemplateToken Constant(string value) => new ConstantTemplateToken(value); + public static TemplateToken RepeatingProperty(string name, char separator, string? format = null) => new RepeatingPropertyTemplateToken(name, separator, format); + public enum TemplateTokenType { PrimaryDelimiter, Delimiter, Property, - Constant + Constant, + RepeatingProperty } } @@ -26,3 +29,5 @@ public sealed record DelimiterTemplateToken(char Value) : TemplateToken(Template public sealed record PropertyTemplateToken(string Name, string? Format = null) : TemplateToken(TemplateTokenType.Property); public sealed record ConstantTemplateToken(string Value) : TemplateToken(TemplateTokenType.Constant); + +public sealed record RepeatingPropertyTemplateToken(string Name, char Separator, string? Format = null) : TemplateToken(TemplateTokenType.RepeatingProperty); diff --git a/src/CompositeKey.Analyzers.Common/Validation/PropertyValidation.cs b/src/CompositeKey.Analyzers.Common/Validation/PropertyValidation.cs index a82eda4..83f04b7 100644 --- a/src/CompositeKey.Analyzers.Common/Validation/PropertyValidation.cs +++ b/src/CompositeKey.Analyzers.Common/Validation/PropertyValidation.cs @@ -40,6 +40,15 @@ public record PropertyAccessibilityInfo( bool HasGetter, bool HasSetter); + public record RepeatingPropertyTypeInfo( + string TypeName, + bool IsList, + bool IsReadOnlyList, + bool IsImmutableArray) + { + public bool IsRepeatingType => IsList || IsReadOnlyList || IsImmutableArray; + } + /// /// Validates that a property has accessible getter and setter. /// @@ -55,6 +64,40 @@ public static PropertyValidationResult ValidatePropertyAccessibility(PropertyAcc return PropertyValidationResult.Success(); } + /// + /// Validates that a property used with repeating syntax is a supported collection type. + /// + public static PropertyValidationResult ValidateRepeatingPropertyType( + string propertyName, + RepeatingPropertyTypeInfo repeatingTypeInfo) + { + if (!repeatingTypeInfo.IsRepeatingType) + { + return PropertyValidationResult.Failure( + DiagnosticDescriptors.RepeatingPropertyMustUseCollectionType, + propertyName); + } + + return PropertyValidationResult.Success(); + } + + /// + /// Validates that a repeating type property uses repeating syntax. + /// + public static PropertyValidationResult ValidateNonRepeatingPropertyType( + string propertyName, + RepeatingPropertyTypeInfo repeatingTypeInfo) + { + if (repeatingTypeInfo.IsRepeatingType) + { + return PropertyValidationResult.Failure( + DiagnosticDescriptors.RepeatingTypeMustUseRepeatingSyntax, + propertyName); + } + + return PropertyValidationResult.Success(); + } + /// /// Validates that a property format specifier is compatible with the property type. /// diff --git a/src/CompositeKey.Analyzers.Common/Validation/TemplateValidation.cs b/src/CompositeKey.Analyzers.Common/Validation/TemplateValidation.cs index f1eb219..2b6aaff 100644 --- a/src/CompositeKey.Analyzers.Common/Validation/TemplateValidation.cs +++ b/src/CompositeKey.Analyzers.Common/Validation/TemplateValidation.cs @@ -86,12 +86,40 @@ public static TemplateValidationResult ValidatePropertyReferences( } } + var repeatingPropertyTokens = tokens.OfType(); + foreach (var token in repeatingPropertyTokens) + { + var property = availableProperties.FirstOrDefault(p => p.Name == token.Name); + if (property == null) + { + return TemplateValidationResult.Failure( + DiagnosticDescriptors.PropertyMustHaveAccessibleGetterAndSetter, + token.Name); + } + + var accessibilityInfo = new PropertyValidation.PropertyAccessibilityInfo( + property.Name, + property.HasGetter, + property.HasSetter); + + var accessibilityResult = PropertyValidation.ValidatePropertyAccessibility(accessibilityInfo); + if (!accessibilityResult.IsSuccess) + { + return TemplateValidationResult.Failure( + accessibilityResult.Descriptor, + accessibilityResult.MessageArgs); + } + } + return TemplateValidationResult.Success(); } public static bool HasValidTemplateStructure(List tokens) { - return tokens.Any() && tokens.Any(t => t.Type is TemplateToken.TemplateTokenType.Property or TemplateToken.TemplateTokenType.Constant); + return tokens.Any() && tokens.Any(t => t.Type is + TemplateToken.TemplateTokenType.Property or + TemplateToken.TemplateTokenType.Constant or + TemplateToken.TemplateTokenType.RepeatingProperty); } public static bool ValidatePartitionAndSortKeyStructure( @@ -109,16 +137,122 @@ public static bool ValidatePartitionAndSortKeyStructure( // Check if there are value parts before the delimiter (partition key) var hasPartitionKeyValues = tokens .Take(primaryDelimiterIndex) - .Any(t => t.Type == TemplateToken.TemplateTokenType.Property || t.Type == TemplateToken.TemplateTokenType.Constant); + .Any(t => t.Type is TemplateToken.TemplateTokenType.Property or TemplateToken.TemplateTokenType.Constant or TemplateToken.TemplateTokenType.RepeatingProperty); // Check if there are value parts after the delimiter (sort key) var hasSortKeyValues = tokens .Skip(primaryDelimiterIndex + 1) - .Any(t => t.Type == TemplateToken.TemplateTokenType.Property || t.Type == TemplateToken.TemplateTokenType.Constant); + .Any(t => t.Type is TemplateToken.TemplateTokenType.Property or TemplateToken.TemplateTokenType.Constant or TemplateToken.TemplateTokenType.RepeatingProperty); return hasPartitionKeyValues && hasSortKeyValues; } + /// + /// Validates that repeating properties are the last value part in their key section. + /// + public static TemplateValidationResult ValidateRepeatingPropertyPosition(List tokens) + { + var repeatingTokens = tokens + .Where(t => t.Type == TemplateToken.TemplateTokenType.RepeatingProperty) + .ToList(); + + if (repeatingTokens.Count == 0) + return TemplateValidationResult.Success(); + + // Determine if this is a composite key + var primaryDelimiterIndex = tokens.FindIndex(t => t.Type == TemplateToken.TemplateTokenType.PrimaryDelimiter); + + foreach (var repeatingToken in repeatingTokens) + { + var tokenIndex = tokens.IndexOf(repeatingToken); + var repeatingName = ((RepeatingPropertyTemplateToken)repeatingToken).Name; + + // Determine which section this token belongs to + List section; + if (primaryDelimiterIndex < 0) + { + // Simple key: the entire token list is the section + section = tokens; + } + else if (tokenIndex < primaryDelimiterIndex) + { + // Partition key section + section = tokens.Take(primaryDelimiterIndex).ToList(); + } + else + { + // Sort key section + section = tokens.Skip(primaryDelimiterIndex + 1).ToList(); + } + + // Find the last value token in the section + var lastValueToken = section + .LastOrDefault(t => t.Type is + TemplateToken.TemplateTokenType.Property or + TemplateToken.TemplateTokenType.Constant or + TemplateToken.TemplateTokenType.RepeatingProperty); + + if (lastValueToken != repeatingToken) + { + return TemplateValidationResult.Failure( + DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, + repeatingName); + } + } + + return TemplateValidationResult.Success(); + } + + /// + /// Validates that there is at most one repeating property per key section. + /// + public static TemplateValidationResult ValidateRepeatingPropertyCount(List tokens) + { + var repeatingTokens = tokens + .Where(t => t.Type == TemplateToken.TemplateTokenType.RepeatingProperty) + .Cast() + .ToList(); + + if (repeatingTokens.Count <= 1) + return TemplateValidationResult.Success(); + + // Determine if this is a composite key + var primaryDelimiterIndex = tokens.FindIndex(t => t.Type == TemplateToken.TemplateTokenType.PrimaryDelimiter); + + if (primaryDelimiterIndex < 0) + { + // Simple key: more than one repeating property is invalid + return TemplateValidationResult.Failure( + DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, + repeatingTokens[1].Name); + } + + // Composite key: check each section separately + var pkRepeating = repeatingTokens + .Where(t => tokens.IndexOf(t) < primaryDelimiterIndex) + .ToList(); + + var skRepeating = repeatingTokens + .Where(t => tokens.IndexOf(t) > primaryDelimiterIndex) + .ToList(); + + if (pkRepeating.Count > 1) + { + return TemplateValidationResult.Failure( + DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, + pkRepeating[1].Name); + } + + if (skRepeating.Count > 1) + { + return TemplateValidationResult.Failure( + DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, + skRepeating[1].Name); + } + + return TemplateValidationResult.Success(); + } + public static TokenizeResult TokenizeTemplateString(string templateString, char? primaryKeySeparator) { var tokenizer = new TemplateStringTokenizer(primaryKeySeparator); @@ -173,6 +307,16 @@ public static TemplateValidationResult ValidateTemplateFormat( if (!propertyValidation.IsSuccess) return propertyValidation; + // Validate repeating property count (at most one per key section) + var repeatingCountValidation = ValidateRepeatingPropertyCount(tokenizationResult.Tokens); + if (!repeatingCountValidation.IsSuccess) + return repeatingCountValidation; + + // Validate repeating property position (must be last value part in section) + var repeatingPositionValidation = ValidateRepeatingPropertyPosition(tokenizationResult.Tokens); + if (!repeatingPositionValidation.IsSuccess) + return repeatingPositionValidation; + return TemplateValidationResult.Success(); } } diff --git a/src/CompositeKey.Analyzers.UnitTests/Analyzers/PropertyAnalyzerTests.cs b/src/CompositeKey.Analyzers.UnitTests/Analyzers/PropertyAnalyzerTests.cs index f2567dd..f075d99 100644 --- a/src/CompositeKey.Analyzers.UnitTests/Analyzers/PropertyAnalyzerTests.cs +++ b/src/CompositeKey.Analyzers.UnitTests/Analyzers/PropertyAnalyzerTests.cs @@ -558,6 +558,122 @@ public partial record EntityKey } } + /// + /// Tests for repeating property collection type validation (COMPOSITE0009/COMPOSITE0010). + /// + public class RepeatingPropertyValidation + { + [Fact] + public async Task RepeatingPropertyWithListType_ProducesNoDiagnostics() + { + // Arrange + var test = new PropertyAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey("PREFIX_{Tags...#}")] + public partial record TaggedKey + { + public List Tags { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingPropertyWithIReadOnlyListType_ProducesNoDiagnostics() + { + // Arrange + var test = new PropertyAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey("PREFIX_{Tags...#}")] + public partial record TaggedKey + { + public IReadOnlyList Tags { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingPropertyWithNonCollectionType_ReportsError() + { + // Arrange + var test = new PropertyAnalyzerTest + { + TestCode = """ + using CompositeKey; + + [CompositeKey("PREFIX_{Name...#}")] + public partial record InvalidKey + { + public string {|COMPOSITE0009:Name|} { get; set; } = ""; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingPropertyWithImmutableArrayType_ProducesNoDiagnostics() + { + // Arrange + var test = new PropertyAnalyzerTest + { + TestCode = """ + using System; + using System.Collections.Immutable; + using CompositeKey; + + [CompositeKey("PREFIX_{Ids:D...#}")] + public partial record TestKey + { + public ImmutableArray Ids { get; set; } + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingTypeWithoutRepeatingSyntax_ReportsError() + { + // Arrange + var test = new PropertyAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey("PREFIX_{Tags}")] + public partial record InvalidKey + { + public List {|COMPOSITE0010:{|COMPOSITE0008:Tags|}|} { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + } + /// /// Test fixture for PropertyAnalyzer using the analyzer test infrastructure. /// diff --git a/src/CompositeKey.Analyzers.UnitTests/Analyzers/TemplateStringAnalyzerTests.cs b/src/CompositeKey.Analyzers.UnitTests/Analyzers/TemplateStringAnalyzerTests.cs index 39c7e1b..f435c6b 100644 --- a/src/CompositeKey.Analyzers.UnitTests/Analyzers/TemplateStringAnalyzerTests.cs +++ b/src/CompositeKey.Analyzers.UnitTests/Analyzers/TemplateStringAnalyzerTests.cs @@ -744,6 +744,103 @@ public partial record TestKey( } } + /// + /// Tests for repeating property position validation (COMPOSITE0011). + /// + public class RepeatingPropertyPositionValidation + { + [Fact] + public async Task RepeatingPropertyAtEnd_ProducesNoDiagnostics() + { + // Arrange + var test = new TemplateStringAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey("PREFIX_{Tags...#}")] + public partial record TestKey + { + public List Tags { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingPropertyInCompositeKeySortKeyAtEnd_ProducesNoDiagnostics() + { + // Arrange + var test = new TemplateStringAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey("{UserId}|TAG_{Tags...#}", PrimaryKeySeparator = '|')] + public partial record TestKey + { + public string UserId { get; set; } = ""; + public List Tags { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task MultipleRepeatingPropertiesInSameSection_ReportsError() + { + // Arrange + var test = new TemplateStringAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey({|COMPOSITE0011:"{Tags...#}-{Items...,}"|})] + public partial record TestKey + { + public List Tags { get; set; } = []; + public List Items { get; set; } = []; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + + [Fact] + public async Task RepeatingPropertyNotAtEnd_ReportsError() + { + // Arrange + var test = new TemplateStringAnalyzerTest + { + TestCode = """ + using System.Collections.Generic; + using CompositeKey; + + [CompositeKey({|COMPOSITE0011:"{Tags...#}_{Suffix}"|})] + public partial record TestKey + { + public List Tags { get; set; } = []; + public string Suffix { get; set; } = ""; + } + """ + }; + + // Act & Assert + await test.RunAsync(); + } + } + /// /// Tests for analyzer public API and metadata. /// @@ -760,11 +857,12 @@ public void TemplateStringAnalyzer_SupportsExpectedDiagnostics() // Assert supportedDiagnostics.ShouldNotBeEmpty(); - supportedDiagnostics.Length.ShouldBe(2); + supportedDiagnostics.Length.ShouldBe(3); var diagnosticIds = supportedDiagnostics.Select(d => d.Id).ToList(); diagnosticIds.ShouldContain("COMPOSITE0005"); // EmptyOrInvalidTemplateString diagnosticIds.ShouldContain("COMPOSITE0006"); // PrimaryKeySeparatorMissingFromTemplateString + diagnosticIds.ShouldContain("COMPOSITE0011"); // RepeatingPropertyMustBeLastPart } [Fact] diff --git a/src/CompositeKey.Analyzers/Analyzers/PropertyAnalyzer.cs b/src/CompositeKey.Analyzers/Analyzers/PropertyAnalyzer.cs index 3f83b7e..c869547 100644 --- a/src/CompositeKey.Analyzers/Analyzers/PropertyAnalyzer.cs +++ b/src/CompositeKey.Analyzers/Analyzers/PropertyAnalyzer.cs @@ -21,7 +21,9 @@ public sealed class PropertyAnalyzer : CompositeKeyAnalyzerBase /// public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( DiagnosticDescriptors.PropertyMustHaveAccessibleGetterAndSetter, - DiagnosticDescriptors.PropertyHasInvalidOrUnsupportedFormat); + DiagnosticDescriptors.PropertyHasInvalidOrUnsupportedFormat, + DiagnosticDescriptors.RepeatingPropertyMustUseCollectionType, + DiagnosticDescriptors.RepeatingTypeMustUseRepeatingSyntax); /// /// Analyzes properties referenced in a CompositeKey template string. @@ -54,58 +56,145 @@ protected override void AnalyzeCompositeKeyType( foreach (var token in tokenizationResult.Tokens) { - if (token is not PropertyTemplateToken propertyToken) - continue; + if (token is PropertyTemplateToken propertyToken) + { + var property = properties.FirstOrDefault(p => p.Name == propertyToken.Name); + if (property is null) + continue; // Property not found is handled by template validation - var property = properties.FirstOrDefault(p => p.Name == propertyToken.Name); - if (property is null) - continue; // Property not found is handled by template validation + var accessibilityInfo = new PropertyValidation.PropertyAccessibilityInfo( + Name: property.Name, + HasGetter: property.GetMethod is not null, + HasSetter: property.SetMethod is not null); - var accessibilityInfo = new PropertyValidation.PropertyAccessibilityInfo( - Name: property.Name, - HasGetter: property.GetMethod is not null, - HasSetter: property.SetMethod is not null); + var accessibilityResult = PropertyValidation.ValidatePropertyAccessibility(accessibilityInfo); + if (!accessibilityResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + accessibilityResult); + } - var accessibilityResult = PropertyValidation.ValidatePropertyAccessibility(accessibilityInfo); - if (!accessibilityResult.IsSuccess) - { - ReportPropertyDiagnostic( - context, - typeDeclaration, - property, - accessibilityResult); - } + // Check if a regular property is a repeating type (should use repeating syntax) + var repeatingTypeInfo = CreateRepeatingPropertyTypeInfo(property, context.Compilation); + var nonRepeatingResult = PropertyValidation.ValidateNonRepeatingPropertyType( + property.Name, + repeatingTypeInfo); + + if (!nonRepeatingResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + nonRepeatingResult); + } - var typeInfo = CreatePropertyTypeInfo(property, context.Compilation); + var typeInfo = CreatePropertyTypeInfo(property, context.Compilation); - if (!string.IsNullOrEmpty(propertyToken.Format)) - { - var formatResult = PropertyValidation.ValidatePropertyFormat( + if (!string.IsNullOrEmpty(propertyToken.Format)) + { + var formatResult = PropertyValidation.ValidatePropertyFormat( + property.Name, + typeInfo, + propertyToken.Format); + + if (!formatResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + formatResult); + } + } + + var typeCompatibilityResult = PropertyValidation.ValidatePropertyTypeCompatibility( property.Name, - typeInfo, - propertyToken.Format); + typeInfo); - if (!formatResult.IsSuccess) + if (!typeCompatibilityResult.IsSuccess) { ReportPropertyDiagnostic( context, typeDeclaration, property, - formatResult); + typeCompatibilityResult); } } + else if (token is RepeatingPropertyTemplateToken repeatingToken) + { + var property = properties.FirstOrDefault(p => p.Name == repeatingToken.Name); + if (property is null) + continue; // Property not found is handled by template validation - var typeCompatibilityResult = PropertyValidation.ValidatePropertyTypeCompatibility( - property.Name, - typeInfo); + var accessibilityInfo = new PropertyValidation.PropertyAccessibilityInfo( + Name: property.Name, + HasGetter: property.GetMethod is not null, + HasSetter: property.SetMethod is not null); - if (!typeCompatibilityResult.IsSuccess) - { - ReportPropertyDiagnostic( - context, - typeDeclaration, - property, - typeCompatibilityResult); + var accessibilityResult = PropertyValidation.ValidatePropertyAccessibility(accessibilityInfo); + if (!accessibilityResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + accessibilityResult); + } + + // Check if the property is a valid repeating type + var repeatingTypeInfo = CreateRepeatingPropertyTypeInfo(property, context.Compilation); + var repeatingResult = PropertyValidation.ValidateRepeatingPropertyType( + property.Name, + repeatingTypeInfo); + + if (!repeatingResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + repeatingResult); + continue; + } + + // Validate format/type compatibility for the inner type T + var innerTypeInfo = CreateInnerTypeInfo(property, context.Compilation); + if (innerTypeInfo is null) + continue; + + if (!string.IsNullOrEmpty(repeatingToken.Format)) + { + var formatResult = PropertyValidation.ValidatePropertyFormat( + property.Name, + innerTypeInfo, + repeatingToken.Format); + + if (!formatResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + formatResult); + } + } + + var typeCompatibilityResult = PropertyValidation.ValidatePropertyTypeCompatibility( + property.Name, + innerTypeInfo); + + if (!typeCompatibilityResult.IsSuccess) + { + ReportPropertyDiagnostic( + context, + typeDeclaration, + property, + typeCompatibilityResult); + } } } } @@ -149,6 +238,69 @@ private static ImmutableArray GetTypeProperties(INamedTypeSymbo .ToImmutableArray(); } + /// + /// Creates RepeatingPropertyTypeInfo from a property symbol for repeating type detection. + /// + private static PropertyValidation.RepeatingPropertyTypeInfo CreateRepeatingPropertyTypeInfo( + IPropertySymbol property, + Compilation compilation) + { + var propertyType = property.Type; + + if (propertyType is not INamedTypeSymbol namedType || !namedType.IsGenericType) + { + return new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: propertyType.ToDisplayString(), + IsList: false, + IsReadOnlyList: false, + IsImmutableArray: false); + } + + var originalDefinition = namedType.OriginalDefinition; + + var listType = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1"); + var readOnlyListType = compilation.GetTypeByMetadataName("System.Collections.Generic.IReadOnlyList`1"); + var immutableArrayType = compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableArray`1"); + + return new PropertyValidation.RepeatingPropertyTypeInfo( + TypeName: propertyType.ToDisplayString(), + IsList: listType is not null && SymbolEqualityComparer.Default.Equals(originalDefinition, listType), + IsReadOnlyList: readOnlyListType is not null && SymbolEqualityComparer.Default.Equals(originalDefinition, readOnlyListType), + IsImmutableArray: immutableArrayType is not null && SymbolEqualityComparer.Default.Equals(originalDefinition, immutableArrayType)); + } + + /// + /// Creates PropertyTypeInfo for the inner type T of a collection property. + /// + private static PropertyValidation.PropertyTypeInfo? CreateInnerTypeInfo( + IPropertySymbol property, + Compilation compilation) + { + if (property.Type is not INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: > 0 } namedType) + return null; + + var innerType = namedType.TypeArguments[0]; + + var guidType = compilation.GetTypeByMetadataName("System.Guid"); + var stringType = compilation.GetSpecialType(SpecialType.System_String); + + var isGuid = SymbolEqualityComparer.Default.Equals(innerType, guidType); + var isString = SymbolEqualityComparer.Default.Equals(innerType, stringType); + var isEnum = innerType.TypeKind == TypeKind.Enum; + + var interfaces = innerType.AllInterfaces; + var isSpanParsable = interfaces.Any(i => i.ToDisplayString().StartsWith("System.ISpanParsable", StringComparison.Ordinal)); + var isSpanFormattable = interfaces.Any(i => i.ToDisplayString().Equals("System.ISpanFormattable", StringComparison.Ordinal)); + + return new PropertyValidation.PropertyTypeInfo( + TypeName: innerType.ToDisplayString(), + IsGuid: isGuid, + IsString: isString, + IsEnum: isEnum, + IsSpanParsable: isSpanParsable, + IsSpanFormattable: isSpanFormattable); + } + /// /// Creates PropertyTypeInfo from a property symbol for validation. /// diff --git a/src/CompositeKey.Analyzers/Analyzers/TemplateStringAnalyzer.cs b/src/CompositeKey.Analyzers/Analyzers/TemplateStringAnalyzer.cs index 8bae8c6..46e2e75 100644 --- a/src/CompositeKey.Analyzers/Analyzers/TemplateStringAnalyzer.cs +++ b/src/CompositeKey.Analyzers/Analyzers/TemplateStringAnalyzer.cs @@ -20,7 +20,8 @@ public sealed class TemplateStringAnalyzer : CompositeKeyAnalyzerBase /// public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( DiagnosticDescriptors.EmptyOrInvalidTemplateString, - DiagnosticDescriptors.PrimaryKeySeparatorMissingFromTemplateString); + DiagnosticDescriptors.PrimaryKeySeparatorMissingFromTemplateString, + DiagnosticDescriptors.RepeatingPropertyMustBeLastPart); /// /// Analyzes a CompositeKey-annotated type for template string requirements. @@ -83,8 +84,24 @@ protected override void AnalyzeCompositeKeyType( if (!TemplateValidation.ValidatePartitionAndSortKeyStructure(tokenizationResult.Tokens, out _)) { ReportEmptyOrInvalidTemplate(context, typeDeclaration, templateString); + return; } } + + // Validate repeating property count (at most one per key section) + var repeatingCountValidation = TemplateValidation.ValidateRepeatingPropertyCount(tokenizationResult.Tokens); + if (!repeatingCountValidation.IsSuccess) + { + ReportTemplateValidationError(context, typeDeclaration, repeatingCountValidation, templateString); + return; + } + + // Validate repeating property position (must be last value part in section) + var repeatingPositionValidation = TemplateValidation.ValidateRepeatingPropertyPosition(tokenizationResult.Tokens); + if (!repeatingPositionValidation.IsSuccess) + { + ReportTemplateValidationError(context, typeDeclaration, repeatingPositionValidation, templateString); + } } /// diff --git a/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeyTests.cs b/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeyTests.cs index 064ad79..69a2b7a 100644 --- a/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeyTests.cs +++ b/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeyTests.cs @@ -322,4 +322,197 @@ public static object[][] CompositePrimaryKeyWithSamePropertyUsedTwice_InvalidPri ]; #endregion + + #region CompositeWithRepeatingSort + + [Fact] + public static void CompositeWithRepeatingSort_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var compositeKey = new CompositeWithRepeatingSort(Guid.NewGuid(), [Guid.NewGuid(), Guid.NewGuid()]); + + var result = CompositeWithRepeatingSort.Parse(compositeKey.ToString()); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(compositeKey.TenantId); + result.LocationId.ShouldBe(compositeKey.LocationId); + } + + [Fact] + public static void CompositeWithRepeatingSort_ToString_ShouldReturnCorrectlyFormattedString() + { + var tenantId = Guid.NewGuid(); + var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; + var compositeKey = new CompositeWithRepeatingSort(tenantId, ids); + + string result = compositeKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"{tenantId}|LOCATION#{ids[0]}#{ids[1]}"); + } + + [Fact] + public static void CompositeWithRepeatingSort_ToPartitionKeyString_ShouldReturnCorrectlyFormattedString() + { + var tenantId = Guid.NewGuid(); + var compositeKey = new CompositeWithRepeatingSort(tenantId, [Guid.NewGuid()]); + + string result = compositeKey.ToPartitionKeyString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"{tenantId}"); + } + + [Fact] + public static void CompositeWithRepeatingSort_ToSortKeyString_ShouldReturnCorrectlyFormattedString() + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; + var compositeKey = new CompositeWithRepeatingSort(Guid.NewGuid(), ids); + + string result = compositeKey.ToSortKeyString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"LOCATION#{ids[0]}#{ids[1]}"); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void CompositeWithRepeatingSort_ToSortKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var compositeKey = new CompositeWithRepeatingSort(Guid.NewGuid(), ids); + + string result = compositeKey.ToSortKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "LOCATION", + 0 when includeTrailingDelimiter => "LOCATION#", + 1 when !includeTrailingDelimiter => $"LOCATION#{ids[0]}", + 1 when includeTrailingDelimiter => $"LOCATION#{ids[0]}#", + 2 => $"LOCATION#{ids[0]}#{ids[1]}", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + [Fact] + public static void CompositeWithRepeatingSort_Parse_WithValidPrimaryKey_ShouldReturnCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var result = CompositeWithRepeatingSort.Parse($"{tenantId}|LOCATION#{id1}#{id2}"); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(CompositeWithRepeatingSort_InvalidPrimaryKeys))] + public static void CompositeWithRepeatingSort_Parse_WithInvalidPrimaryKey_ShouldThrowFormatException(string primaryKey) + { + var act = () => CompositeWithRepeatingSort.Parse(primaryKey); + act.ShouldThrow(); + } + + [Fact] + public static void CompositeWithRepeatingSort_Parse_WithValidPartitionKeyAndSortKey_ShouldReturnCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var result = CompositeWithRepeatingSort.Parse($"{tenantId}", $"LOCATION#{id1}#{id2}"); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(CompositeWithRepeatingSort_InvalidPartitionKeyAndSortKeys))] + public static void CompositeWithRepeatingSort_Parse_WithInvalidPartitionKeyAndSortKey_ShouldThrowFormatException(string partitionKey, string sortKey) + { + var act = () => CompositeWithRepeatingSort.Parse(partitionKey, sortKey); + act.ShouldThrow(); + } + + [Fact] + public static void CompositeWithRepeatingSort_TryParse_WithValidPrimaryKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + CompositeWithRepeatingSort.TryParse($"{tenantId}|LOCATION#{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(CompositeWithRepeatingSort_InvalidPrimaryKeys))] + public static void CompositeWithRepeatingSort_TryParse_WithInvalidPrimaryKey_ShouldReturnFalse(string primaryKey) + { + CompositeWithRepeatingSort.TryParse(primaryKey, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Fact] + public static void CompositeWithRepeatingSort_TryParse_WithValidPartitionKeyAndSortKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + CompositeWithRepeatingSort.TryParse($"{tenantId}", $"LOCATION#{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(CompositeWithRepeatingSort_InvalidPartitionKeyAndSortKeys))] + public static void CompositeWithRepeatingSort_TryParse_WithInvalidPartitionKeyAndSortKey_ShouldReturnFalse(string partitionKey, string sortKey) + { + CompositeWithRepeatingSort.TryParse(partitionKey, sortKey, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + public static object[][] CompositeWithRepeatingSort_InvalidPrimaryKeys() => + [ + [""], + ["a"], + ["a|b"], + ["not-a-guid|LOCATION#not-a-guid"], + ["eccd98c4-d484-4429-896d-8fcdd77c6327|WRONG#eccd98c4-d484-4429-896d-8fcdd77c6328"] + ]; + + public static object[][] CompositeWithRepeatingSort_InvalidPartitionKeyAndSortKeys() => + [ + ["a", "b"], + ["not-a-guid", "LOCATION#not-a-guid"], + ["eccd98c4-d484-4429-896d-8fcdd77c6327", "WRONG#eccd98c4-d484-4429-896d-8fcdd77c6328"], + ["eccd98c4-d484-4429-896d-8fcdd77c6327", "LOCATION#not-a-guid"] + ]; + + #endregion } diff --git a/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeys.cs b/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeys.cs index 05cbb31..838399f 100644 --- a/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeys.cs +++ b/src/CompositeKey.SourceGeneration.FunctionalTests/CompositePrimaryKeys.cs @@ -12,3 +12,6 @@ public enum EnumType { One, Two, Three }; [CompositeKey("{Id}#{Id}", PrimaryKeySeparator = '#')] public sealed partial record CompositePrimaryKeyWithSamePropertyUsedTwice(Guid Id); + +[CompositeKey("{TenantId}|LOCATION#{LocationId...#}", PrimaryKeySeparator = '|')] +public sealed partial record CompositeWithRepeatingSort(Guid TenantId, IReadOnlyList LocationId); diff --git a/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeyTests.cs b/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeyTests.cs index bf9ac3c..967fd49 100644 --- a/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeyTests.cs +++ b/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeyTests.cs @@ -329,4 +329,855 @@ public static void PrimaryKeyWithFastPathFormatting_ToPartitionKeyString_WithInv } #endregion + + #region RepeatingGuidPrimaryKey + + [Fact] + public static void RepeatingGuidPrimaryKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new RepeatingGuidPrimaryKey([Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]); + + var result = RepeatingGuidPrimaryKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.LocationId.ShouldBe(primaryKey.LocationId); + } + + [Fact] + public static void RepeatingGuidPrimaryKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new RepeatingGuidPrimaryKey(ids); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"{ids[0]}#{ids[1]}"); + } + + [Fact] + public static void RepeatingGuidPrimaryKey_ToString_WithSingleItem_ShouldReturnCorrectlyFormattedString() + { + var id = Guid.NewGuid(); + var primaryKey = new RepeatingGuidPrimaryKey([id]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"{id}"); + } + + [Fact] + public static void RepeatingGuidPrimaryKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var id3 = Guid.NewGuid(); + + var result = RepeatingGuidPrimaryKey.Parse($"{id1}#{id2}#{id3}"); + + result.ShouldNotBeNull(); + result.LocationId.Count.ShouldBe(3); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + result.LocationId[2].ShouldBe(id3); + } + + [Theory, MemberData(nameof(RepeatingGuidPrimaryKey_InvalidInputs))] + public static void RepeatingGuidPrimaryKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => RepeatingGuidPrimaryKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void RepeatingGuidPrimaryKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + RepeatingGuidPrimaryKey.TryParse($"{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(RepeatingGuidPrimaryKey_InvalidInputs))] + public static void RepeatingGuidPrimaryKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + RepeatingGuidPrimaryKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void RepeatingGuidPrimaryKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new RepeatingGuidPrimaryKey(ids); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => $"{ids[0]}", + 0 when includeTrailingDelimiter => $"{ids[0]}#", + 1 when !includeTrailingDelimiter => $"{ids[0]}#{ids[1]}", + 1 when includeTrailingDelimiter => $"{ids[0]}#{ids[1]}#", + 2 => $"{ids[0]}#{ids[1]}#{ids[2]}", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] RepeatingGuidPrimaryKey_InvalidInputs() => + [ + [""], + ["not-a-guid"], + ["#"], + ["not-a-guid#also-not-a-guid"] + ]; + + #endregion + + #region HierarchicalLocationKey + + [Fact] + public static void HierarchicalLocationKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new HierarchicalLocationKey([Guid.NewGuid(), Guid.NewGuid()]); + + var result = HierarchicalLocationKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.LocationId.ShouldBe(primaryKey.LocationId); + } + + [Fact] + public static void HierarchicalLocationKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new HierarchicalLocationKey(ids); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"LOCATION#{ids[0]}#{ids[1]}"); + } + + [Fact] + public static void HierarchicalLocationKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var result = HierarchicalLocationKey.Parse($"LOCATION#{id1}#{id2}"); + + result.ShouldNotBeNull(); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Fact] + public static void HierarchicalLocationKey_Parse_WithSingleItem_ShouldReturnCorrectlyParsedRecord() + { + var id = Guid.NewGuid(); + + var result = HierarchicalLocationKey.Parse($"LOCATION#{id}"); + + result.ShouldNotBeNull(); + result.LocationId.Count.ShouldBe(1); + result.LocationId[0].ShouldBe(id); + } + + [Theory, MemberData(nameof(HierarchicalLocationKey_InvalidInputs))] + public static void HierarchicalLocationKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => HierarchicalLocationKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void HierarchicalLocationKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + HierarchicalLocationKey.TryParse($"LOCATION#{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(HierarchicalLocationKey_InvalidInputs))] + public static void HierarchicalLocationKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + HierarchicalLocationKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void HierarchicalLocationKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var ids = new List { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new HierarchicalLocationKey(ids); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "LOCATION", + 0 when includeTrailingDelimiter => "LOCATION#", + 1 when !includeTrailingDelimiter => $"LOCATION#{ids[0]}", + 1 when includeTrailingDelimiter => $"LOCATION#{ids[0]}#", + 2 => $"LOCATION#{ids[0]}#{ids[1]}", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] HierarchicalLocationKey_InvalidInputs() => + [ + [""], + ["a"], + ["WRONG#15cd670a-89c7-4c7f-8245-507ec9e41c8b"], + ["LOCATION#not-a-guid"], + ["LOCATION"] + ]; + + #endregion + + #region TaggedEntityKey + + [Fact] + public static void TaggedEntityKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new TaggedEntityKey("MyType", ["alpha", "beta", "gamma"]); + + var result = TaggedEntityKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.Type.ShouldBe(primaryKey.Type); + result.Tags.ShouldBe(primaryKey.Tags); + } + + [Fact] + public static void TaggedEntityKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var primaryKey = new TaggedEntityKey("Entity", ["tag1", "tag2"]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe("Entity#tag1,tag2"); + } + + [Fact] + public static void TaggedEntityKey_ToString_WithSingleTag_ShouldReturnCorrectlyFormattedString() + { + var primaryKey = new TaggedEntityKey("Entity", ["tag1"]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe("Entity#tag1"); + } + + [Fact] + public static void TaggedEntityKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var result = TaggedEntityKey.Parse("MyType#alpha,beta,gamma"); + + result.ShouldNotBeNull(); + result.Type.ShouldBe("MyType"); + result.Tags.Count.ShouldBe(3); + result.Tags[0].ShouldBe("alpha"); + result.Tags[1].ShouldBe("beta"); + result.Tags[2].ShouldBe("gamma"); + } + + [Fact] + public static void TaggedEntityKey_Parse_WithSingleTag_ShouldReturnCorrectlyParsedRecord() + { + var result = TaggedEntityKey.Parse("MyType#single"); + + result.ShouldNotBeNull(); + result.Type.ShouldBe("MyType"); + result.Tags.Count.ShouldBe(1); + result.Tags[0].ShouldBe("single"); + } + + [Theory, MemberData(nameof(TaggedEntityKey_InvalidInputs))] + public static void TaggedEntityKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => TaggedEntityKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void TaggedEntityKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + TaggedEntityKey.TryParse("MyType#tag1,tag2", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.Type.ShouldBe("MyType"); + result.Tags.Count.ShouldBe(2); + result.Tags[0].ShouldBe("tag1"); + result.Tags[1].ShouldBe("tag2"); + } + + [Theory, MemberData(nameof(TaggedEntityKey_InvalidInputs))] + public static void TaggedEntityKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + TaggedEntityKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void TaggedEntityKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var primaryKey = new TaggedEntityKey("MyType", ["alpha", "beta", "gamma"]); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "MyType", + 0 when includeTrailingDelimiter => "MyType#", + 1 when !includeTrailingDelimiter => "MyType#alpha", + 1 when includeTrailingDelimiter => "MyType#alpha,", + 2 => "MyType#alpha,beta", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] TaggedEntityKey_InvalidInputs() => + [ + [""], + ["a"], + ["#tag"], + ["type#"] + ]; + + #endregion + + #region FastPathRepeatingKey + + [Fact] + public static void FastPathRepeatingKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new FastPathRepeatingKey(Guid.NewGuid(), [Guid.NewGuid(), Guid.NewGuid()]); + + var result = FastPathRepeatingKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(primaryKey.TenantId); + result.LocationId.ShouldBe(primaryKey.LocationId); + } + + [Fact] + public static void FastPathRepeatingKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var tenantId = Guid.NewGuid(); + var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new FastPathRepeatingKey(tenantId, ids); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"{tenantId}#{ids[0]}#{ids[1]}"); + } + + [Fact] + public static void FastPathRepeatingKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var result = FastPathRepeatingKey.Parse($"{tenantId}#{id1}#{id2}"); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Fact] + public static void FastPathRepeatingKey_Parse_WithSingleRepeatingItem_ShouldReturnCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + + var result = FastPathRepeatingKey.Parse($"{tenantId}#{id1}"); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(1); + result.LocationId[0].ShouldBe(id1); + } + + [Theory, MemberData(nameof(FastPathRepeatingKey_InvalidInputs))] + public static void FastPathRepeatingKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => FastPathRepeatingKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void FastPathRepeatingKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var tenantId = Guid.NewGuid(); + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + FastPathRepeatingKey.TryParse($"{tenantId}#{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.TenantId.ShouldBe(tenantId); + result.LocationId.Count.ShouldBe(2); + result.LocationId[0].ShouldBe(id1); + result.LocationId[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(FastPathRepeatingKey_InvalidInputs))] + public static void FastPathRepeatingKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + FastPathRepeatingKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void FastPathRepeatingKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var tenantId = Guid.NewGuid(); + var ids = new List { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new FastPathRepeatingKey(tenantId, ids); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => $"{tenantId}", + 0 when includeTrailingDelimiter => $"{tenantId}#", + 1 when !includeTrailingDelimiter => $"{tenantId}#{ids[0]}", + 1 when includeTrailingDelimiter => $"{tenantId}#{ids[0]}#", + 2 => $"{tenantId}#{ids[0]}#{ids[1]}", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] FastPathRepeatingKey_InvalidInputs() => + [ + [""], + ["a"], + ["not-a-guid#also-not-a-guid"], + ["15cd670a-89c7-4c7f-8245-507ec9e41c8b"] + ]; + + #endregion + + #region RepeatingEnumPrimaryKey + + [Fact] + public static void RepeatingEnumPrimaryKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new RepeatingEnumPrimaryKey([RepeatingEnumPrimaryKey.ItemType.Alpha, RepeatingEnumPrimaryKey.ItemType.Beta, RepeatingEnumPrimaryKey.ItemType.Gamma]); + + var result = RepeatingEnumPrimaryKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.Items.ShouldBe(primaryKey.Items); + } + + [Fact] + public static void RepeatingEnumPrimaryKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var primaryKey = new RepeatingEnumPrimaryKey([RepeatingEnumPrimaryKey.ItemType.Alpha, RepeatingEnumPrimaryKey.ItemType.Beta]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe("ITEMS#Alpha,Beta"); + } + + [Fact] + public static void RepeatingEnumPrimaryKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var result = RepeatingEnumPrimaryKey.Parse("ITEMS#Alpha,Beta,Gamma"); + + result.ShouldNotBeNull(); + result.Items.Count.ShouldBe(3); + result.Items[0].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Alpha); + result.Items[1].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Beta); + result.Items[2].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Gamma); + } + + [Fact] + public static void RepeatingEnumPrimaryKey_Parse_WithSingleItem_ShouldReturnCorrectlyParsedRecord() + { + var result = RepeatingEnumPrimaryKey.Parse("ITEMS#Delta"); + + result.ShouldNotBeNull(); + result.Items.Count.ShouldBe(1); + result.Items[0].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Delta); + } + + [Theory, MemberData(nameof(RepeatingEnumPrimaryKey_InvalidInputs))] + public static void RepeatingEnumPrimaryKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => RepeatingEnumPrimaryKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void RepeatingEnumPrimaryKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + RepeatingEnumPrimaryKey.TryParse("ITEMS#Alpha,Beta", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.Items.Count.ShouldBe(2); + result.Items[0].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Alpha); + result.Items[1].ShouldBe(RepeatingEnumPrimaryKey.ItemType.Beta); + } + + [Theory, MemberData(nameof(RepeatingEnumPrimaryKey_InvalidInputs))] + public static void RepeatingEnumPrimaryKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + RepeatingEnumPrimaryKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void RepeatingEnumPrimaryKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var primaryKey = new RepeatingEnumPrimaryKey([RepeatingEnumPrimaryKey.ItemType.Alpha, RepeatingEnumPrimaryKey.ItemType.Beta, RepeatingEnumPrimaryKey.ItemType.Gamma]); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "ITEMS", + 0 when includeTrailingDelimiter => "ITEMS#", + 1 when !includeTrailingDelimiter => "ITEMS#Alpha", + 1 when includeTrailingDelimiter => "ITEMS#Alpha,", + 2 => "ITEMS#Alpha,Beta", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] RepeatingEnumPrimaryKey_InvalidInputs() => + [ + [""], + ["ITEMS"], + ["WRONG#Alpha,Beta"], + ["ITEMS#InvalidValue"], + ["ITEMS#"] + ]; + + #endregion + + #region RepeatingIntPrimaryKey + + [Fact] + public static void RepeatingIntPrimaryKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new RepeatingIntPrimaryKey([10, 20, 30]); + + var result = RepeatingIntPrimaryKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.Scores.ShouldBe(primaryKey.Scores); + } + + [Fact] + public static void RepeatingIntPrimaryKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var primaryKey = new RepeatingIntPrimaryKey([10, 20]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe("SCORES#10,20"); + } + + [Fact] + public static void RepeatingIntPrimaryKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var result = RepeatingIntPrimaryKey.Parse("SCORES#1,2,3"); + + result.ShouldNotBeNull(); + result.Scores.Count.ShouldBe(3); + result.Scores[0].ShouldBe(1); + result.Scores[1].ShouldBe(2); + result.Scores[2].ShouldBe(3); + } + + [Fact] + public static void RepeatingIntPrimaryKey_Parse_WithSingleItem_ShouldReturnCorrectlyParsedRecord() + { + var result = RepeatingIntPrimaryKey.Parse("SCORES#42"); + + result.ShouldNotBeNull(); + result.Scores.Count.ShouldBe(1); + result.Scores[0].ShouldBe(42); + } + + [Theory, MemberData(nameof(RepeatingIntPrimaryKey_InvalidInputs))] + public static void RepeatingIntPrimaryKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => RepeatingIntPrimaryKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void RepeatingIntPrimaryKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + RepeatingIntPrimaryKey.TryParse("SCORES#10,20", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.Scores.Count.ShouldBe(2); + result.Scores[0].ShouldBe(10); + result.Scores[1].ShouldBe(20); + } + + [Theory, MemberData(nameof(RepeatingIntPrimaryKey_InvalidInputs))] + public static void RepeatingIntPrimaryKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + RepeatingIntPrimaryKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void RepeatingIntPrimaryKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var primaryKey = new RepeatingIntPrimaryKey([10, 20, 30]); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "SCORES", + 0 when includeTrailingDelimiter => "SCORES#", + 1 when !includeTrailingDelimiter => "SCORES#10", + 1 when includeTrailingDelimiter => "SCORES#10,", + 2 => "SCORES#10,20", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] RepeatingIntPrimaryKey_InvalidInputs() => + [ + [""], + ["SCORES"], + ["WRONG#1,2,3"], + ["SCORES#abc"], + ["SCORES#"] + ]; + + #endregion + + #region ImmutableArrayPrimaryKey + + [Fact] + public static void ImmutableArrayPrimaryKey_RoundTripToStringAndParse_ShouldResultInEquivalentKey() + { + var primaryKey = new ImmutableArrayPrimaryKey([Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]); + + var result = ImmutableArrayPrimaryKey.Parse(primaryKey.ToString()); + + result.ShouldNotBeNull(); + result.NodeIds.ShouldBe(primaryKey.NodeIds); + } + + [Fact] + public static void ImmutableArrayPrimaryKey_ToString_ShouldReturnCorrectlyFormattedString() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var primaryKey = new ImmutableArrayPrimaryKey([id1, id2]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"NODES#{id1}#{id2}"); + } + + [Fact] + public static void ImmutableArrayPrimaryKey_ToString_WithSingleItem_ShouldReturnCorrectlyFormattedString() + { + var id = Guid.NewGuid(); + var primaryKey = new ImmutableArrayPrimaryKey([id]); + + string result = primaryKey.ToString(); + + result.ShouldNotBeNullOrEmpty(); + result.ShouldBe($"NODES#{id}"); + } + + [Fact] + public static void ImmutableArrayPrimaryKey_Parse_WithValidKey_ShouldReturnCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var id3 = Guid.NewGuid(); + + var result = ImmutableArrayPrimaryKey.Parse($"NODES#{id1}#{id2}#{id3}"); + + result.ShouldNotBeNull(); + result.NodeIds.Length.ShouldBe(3); + result.NodeIds[0].ShouldBe(id1); + result.NodeIds[1].ShouldBe(id2); + result.NodeIds[2].ShouldBe(id3); + } + + [Fact] + public static void ImmutableArrayPrimaryKey_Parse_WithSingleItem_ShouldReturnCorrectlyParsedRecord() + { + var id = Guid.NewGuid(); + + var result = ImmutableArrayPrimaryKey.Parse($"NODES#{id}"); + + result.ShouldNotBeNull(); + result.NodeIds.Length.ShouldBe(1); + result.NodeIds[0].ShouldBe(id); + } + + [Theory, MemberData(nameof(ImmutableArrayPrimaryKey_InvalidInputs))] + public static void ImmutableArrayPrimaryKey_Parse_WithInvalidKey_ShouldThrowFormatException(string input) + { + var act = () => ImmutableArrayPrimaryKey.Parse(input); + act.ShouldThrow(); + } + + [Fact] + public static void ImmutableArrayPrimaryKey_TryParse_WithValidKey_ShouldReturnTrueAndOutputCorrectlyParsedRecord() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + ImmutableArrayPrimaryKey.TryParse($"NODES#{id1}#{id2}", out var result).ShouldBeTrue(); + + result.ShouldNotBeNull(); + result.NodeIds.Length.ShouldBe(2); + result.NodeIds[0].ShouldBe(id1); + result.NodeIds[1].ShouldBe(id2); + } + + [Theory, MemberData(nameof(ImmutableArrayPrimaryKey_InvalidInputs))] + public static void ImmutableArrayPrimaryKey_TryParse_WithInvalidKey_ShouldReturnFalse(string input) + { + ImmutableArrayPrimaryKey.TryParse(input, out var result).ShouldBeFalse(); + + result.ShouldBeNull(); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(2, false)] + public static void ImmutableArrayPrimaryKey_ToPartitionKeyString_WithSpecificPartIndex_ShouldReturnCorrectlyFormattedString( + int throughPartIndex, bool includeTrailingDelimiter) + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + var primaryKey = new ImmutableArrayPrimaryKey([ids[0], ids[1], ids[2]]); + + string result = primaryKey.ToPartitionKeyString(throughPartIndex, includeTrailingDelimiter); + + result.ShouldNotBeNullOrEmpty(); + + string expected = throughPartIndex switch + { + 0 when !includeTrailingDelimiter => "NODES", + 0 when includeTrailingDelimiter => "NODES#", + 1 when !includeTrailingDelimiter => $"NODES#{ids[0]}", + 1 when includeTrailingDelimiter => $"NODES#{ids[0]}#", + 2 => $"NODES#{ids[0]}#{ids[1]}", + _ => throw new InvalidOperationException() + }; + + result.ShouldBe(expected); + } + + public static object[][] ImmutableArrayPrimaryKey_InvalidInputs() => + [ + [""], + ["NODES"], + ["WRONG#15cd670a-89c7-4c7f-8245-507ec9e41c8b"], + ["NODES#not-a-guid"], + ["NODES#"] + ]; + + #endregion } diff --git a/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeys.cs b/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeys.cs index ecf5c26..04206b4 100644 --- a/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeys.cs +++ b/src/CompositeKey.SourceGeneration.FunctionalTests/PrimaryKeys.cs @@ -1,4 +1,6 @@ -namespace CompositeKey.SourceGeneration.FunctionalTests; +using System.Collections.Immutable; + +namespace CompositeKey.SourceGeneration.FunctionalTests; [CompositeKey("{First}#{Second}")] public sealed partial record GuidOnlyPrimaryKey(Guid First, Guid Second); @@ -23,3 +25,27 @@ public sealed partial record PrimaryKeyWithFastPathFormatting(Guid GuidValue, Pr { public enum EnumType { One, Two, Three, Four, Five, Six, Seven, Eight, Nine }; } + +[CompositeKey("{LocationId...#}")] +public sealed partial record RepeatingGuidPrimaryKey(IReadOnlyList LocationId); + +[CompositeKey("LOCATION#{LocationId...#}")] +public sealed partial record HierarchicalLocationKey(IReadOnlyList LocationId); + +[CompositeKey("{Type}#{Tags...,}")] +public sealed partial record TaggedEntityKey(string Type, List Tags); + +[CompositeKey("{TenantId}#{LocationId:D...#}")] +public sealed partial record FastPathRepeatingKey(Guid TenantId, IReadOnlyList LocationId); + +[CompositeKey("ITEMS#{Items...,}")] +public sealed partial record RepeatingEnumPrimaryKey(IReadOnlyList Items) +{ + public enum ItemType { Alpha, Beta, Gamma, Delta } +} + +[CompositeKey("SCORES#{Scores...,}")] +public sealed partial record RepeatingIntPrimaryKey(IReadOnlyList Scores); + +[CompositeKey("NODES#{NodeIds:D...#}")] +public sealed partial record ImmutableArrayPrimaryKey(ImmutableArray NodeIds); diff --git a/src/CompositeKey.SourceGeneration.UnitTests/CompilationHelper.cs b/src/CompositeKey.SourceGeneration.UnitTests/CompilationHelper.cs index ebe2360..b17dddf 100644 --- a/src/CompositeKey.SourceGeneration.UnitTests/CompilationHelper.cs +++ b/src/CompositeKey.SourceGeneration.UnitTests/CompilationHelper.cs @@ -327,6 +327,35 @@ namespace UnitTests; public partial record KeyWithSamePropertyUsedTwice(Guid Id); """); + public static Compilation CreateCompilationWithRepeatingPropertyKey() => CreateCompilation(""" + using System; + using System.Collections.Generic; + using CompositeKey; + + namespace UnitTests; + + [CompositeKey("TAG_{Tags...#}")] + public partial record TagKey + { + public List Tags { get; set; } = []; + } + """); + + public static Compilation CreateCompilationWithRepeatingPropertyCompositeKey() => CreateCompilation(""" + using System; + using System.Collections.Generic; + using CompositeKey; + + namespace UnitTests; + + [CompositeKey("{UserId}|TAG_{Tags...#}", PrimaryKeySeparator = '|')] + public partial record UserTagKey + { + public Guid UserId { get; set; } + public List Tags { get; set; } = []; + } + """); + public record struct DiagnosticData(DiagnosticSeverity Severity, string FilePath, LinePositionSpan LinePositionSpan, string Message) { public DiagnosticData(DiagnosticSeverity severity, Location location, string message) diff --git a/src/CompositeKey.SourceGeneration.UnitTests/SourceGeneratorTests.cs b/src/CompositeKey.SourceGeneration.UnitTests/SourceGeneratorTests.cs index 4e38110..b0c2a13 100644 --- a/src/CompositeKey.SourceGeneration.UnitTests/SourceGeneratorTests.cs +++ b/src/CompositeKey.SourceGeneration.UnitTests/SourceGeneratorTests.cs @@ -546,6 +546,76 @@ public static void KeyHasMultipleExplicitlyMarkedConstructors_ShouldFailCompilat CompilationHelper.AssertDiagnostics(expectedDiagnostics, result.Diagnostics); } + [Fact] + public static void RepeatingPropertyKey_ShouldSuccessfullyParseModel() + { + var compilation = CompilationHelper.CreateCompilationWithRepeatingPropertyKey(); + var result = CompilationHelper.RunSourceGenerator(compilation, disableDiagnosticValidation: true); + + // Verify the source generator parsed the model (no source generator diagnostics) + result.Diagnostics.Where(d => d.Severity > DiagnosticSeverity.Info).ShouldBeEmpty(); + + // Verify at least one generation spec was created + result.GenerationSpecs.ShouldNotBeEmpty(); + } + + [Fact] + public static void RepeatingPropertyCompositeKey_ShouldSuccessfullyParseModel() + { + var compilation = CompilationHelper.CreateCompilationWithRepeatingPropertyCompositeKey(); + var result = CompilationHelper.RunSourceGenerator(compilation, disableDiagnosticValidation: true); + + // Verify the source generator parsed the model (no source generator diagnostics) + result.Diagnostics.Where(d => d.Severity > DiagnosticSeverity.Info).ShouldBeEmpty(); + + // Verify at least one generation spec was created + result.GenerationSpecs.ShouldNotBeEmpty(); + } + + [Fact] + public static void NonRepeatingTypeWithRepeatingSyntax_ShouldFailCompilation() + { + const string Source = """ + using System; + using CompositeKey; + + namespace UnitTests; + + [CompositeKey("TAG_{Name...#}")] + public partial record InvalidKey(string Name); + """; + + var compilation = CompilationHelper.CreateCompilation(Source); + var result = CompilationHelper.RunSourceGenerator(compilation, disableDiagnosticValidation: true); + + result.Diagnostics.ShouldNotBeEmpty(); + result.Diagnostics.ShouldContain(d => d.Id == "COMPOSITE0009"); + } + + [Fact] + public static void RepeatingTypeWithoutRepeatingSyntax_ShouldFailCompilation() + { + const string Source = """ + using System; + using System.Collections.Generic; + using CompositeKey; + + namespace UnitTests; + + [CompositeKey("TAG_{Tags}")] + public partial record InvalidKey + { + public List Tags { get; set; } = []; + } + """; + + var compilation = CompilationHelper.CreateCompilation(Source); + var result = CompilationHelper.RunSourceGenerator(compilation, disableDiagnosticValidation: true); + + result.Diagnostics.ShouldNotBeEmpty(); + result.Diagnostics.ShouldContain(d => d.Id == "COMPOSITE0010"); + } + [Fact] public static void KeyWithSamePropertyUsedTwice_ShouldSuccessfullyCompile() { diff --git a/src/CompositeKey.SourceGeneration/KnownTypeSymbols.cs b/src/CompositeKey.SourceGeneration/KnownTypeSymbols.cs index 4288229..4c2aae7 100644 --- a/src/CompositeKey.SourceGeneration/KnownTypeSymbols.cs +++ b/src/CompositeKey.SourceGeneration/KnownTypeSymbols.cs @@ -10,6 +10,9 @@ internal sealed class KnownTypeSymbols(Compilation compilation) private Option _setsRequiredMembersAttributeType; private Option _guidType; private Option _stringType; + private Option _listType; + private Option _readOnlyListType; + private Option _immutableArrayType; public Compilation Compilation { get; } = compilation; @@ -23,6 +26,12 @@ internal sealed class KnownTypeSymbols(Compilation compilation) public INamedTypeSymbol? StringType => GetOrResolveType(typeof(string), ref _stringType); + public INamedTypeSymbol? ListType => GetOrResolveType("System.Collections.Generic.List`1", ref _listType); + + public INamedTypeSymbol? ReadOnlyListType => GetOrResolveType("System.Collections.Generic.IReadOnlyList`1", ref _readOnlyListType); + + public INamedTypeSymbol? ImmutableArrayType => GetOrResolveType("System.Collections.Immutable.ImmutableArray`1", ref _immutableArrayType); + private INamedTypeSymbol? GetOrResolveType(Type type, ref Option field) => GetOrResolveType(type.FullName!, ref field); private INamedTypeSymbol? GetOrResolveType(string fullyQualifiedName, ref Option field) diff --git a/src/CompositeKey.SourceGeneration/Model/CollectionType.cs b/src/CompositeKey.SourceGeneration/Model/CollectionType.cs new file mode 100644 index 0000000..8d076a1 --- /dev/null +++ b/src/CompositeKey.SourceGeneration/Model/CollectionType.cs @@ -0,0 +1,9 @@ +namespace CompositeKey.SourceGeneration.Model; + +public enum CollectionType +{ + None, + List, + IReadOnlyList, + ImmutableArray +} diff --git a/src/CompositeKey.SourceGeneration/Model/Key/RepeatingPropertyKeyPart.cs b/src/CompositeKey.SourceGeneration/Model/Key/RepeatingPropertyKeyPart.cs new file mode 100644 index 0000000..2243980 --- /dev/null +++ b/src/CompositeKey.SourceGeneration/Model/Key/RepeatingPropertyKeyPart.cs @@ -0,0 +1,9 @@ +namespace CompositeKey.SourceGeneration.Model.Key; + +public sealed record RepeatingPropertyKeyPart( + PropertySpec Property, + char Separator, + string? Format, + ParseType InnerParseType, + FormatType InnerFormatType, + TypeRef InnerType) : ValueKeyPart; diff --git a/src/CompositeKey.SourceGeneration/Model/PropertySpec.cs b/src/CompositeKey.SourceGeneration/Model/PropertySpec.cs index 3bb1ee5..84f7c7a 100644 --- a/src/CompositeKey.SourceGeneration/Model/PropertySpec.cs +++ b/src/CompositeKey.SourceGeneration/Model/PropertySpec.cs @@ -8,4 +8,5 @@ public sealed record PropertySpec( bool HasGetter, bool HasSetter, bool IsInitOnlySetter, - EnumSpec? EnumSpec); + EnumSpec? EnumSpec, + CollectionType CollectionType = CollectionType.None); diff --git a/src/CompositeKey.SourceGeneration/SourceGenerator.Emitter.cs b/src/CompositeKey.SourceGeneration/SourceGenerator.Emitter.cs index eace61e..6ffb982 100644 --- a/src/CompositeKey.SourceGeneration/SourceGenerator.Emitter.cs +++ b/src/CompositeKey.SourceGeneration/SourceGenerator.Emitter.cs @@ -75,14 +75,15 @@ void WriteParseMethodImplementation() WriteLengthCheck(writer, keyParts, "primaryKey", true); - Func getPrimaryKeyPartInputVariable = static _ => "primaryKey"; + Func getPrimaryKeyPartInputVariable = static _ => "primaryKey"; + string? primaryKeyPartCountVariable = null; if (keyParts.Count > 1) { - WriteSplitImplementation(writer, keyParts, "primaryKey", out string primaryKeyPartRangesVariable, true); - getPrimaryKeyPartInputVariable = i => $"primaryKey[{primaryKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, keyParts, "primaryKey", out string primaryKeyPartRangesVariable, true, out primaryKeyPartCountVariable); + getPrimaryKeyPartInputVariable = indexExpr => $"primaryKey[{primaryKeyPartRangesVariable}[{indexExpr}]]"; } - WriteParsePropertiesImplementation(writer, keyParts, getPrimaryKeyPartInputVariable, true); + WriteParsePropertiesImplementation(writer, keyParts, getPrimaryKeyPartInputVariable, true, primaryKeyPartCountVariable); writer.WriteLine($"return {WriteConstructor(targetTypeSpec)};"); @@ -113,14 +114,15 @@ public static bool TryParse(ReadOnlySpan primaryKey, [{{MaybeNullWhen}}(fa WriteLengthCheck(writer, keyParts, "primaryKey", false); - Func getPrimaryKeyPartInputVariable = static _ => "primaryKey"; + Func getPrimaryKeyPartInputVariable = static _ => "primaryKey"; + string? primaryKeyPartCountVariable = null; if (keyParts.Count > 1) { - WriteSplitImplementation(writer, keyParts, "primaryKey", out string primaryKeyPartRangesVariable, false); - getPrimaryKeyPartInputVariable = i => $"primaryKey[{primaryKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, keyParts, "primaryKey", out string primaryKeyPartRangesVariable, false, out primaryKeyPartCountVariable); + getPrimaryKeyPartInputVariable = indexExpr => $"primaryKey[{primaryKeyPartRangesVariable}[{indexExpr}]]"; } - WriteParsePropertiesImplementation(writer, keyParts, getPrimaryKeyPartInputVariable, false); + WriteParsePropertiesImplementation(writer, keyParts, getPrimaryKeyPartInputVariable, false, primaryKeyPartCountVariable); writer.WriteLines($""" result = {WriteConstructor(targetTypeSpec)}; @@ -232,23 +234,25 @@ void WriteCompositeParseMethodImplementation() WriteLengthCheck(writer, partitionKeyParts, "partitionKey", true); WriteLengthCheck(writer, sortKeyParts, "sortKey", true); - Func getPartitionKeyPartInputVariable = static _ => "partitionKey"; + Func getPartitionKeyPartInputVariable = static _ => "partitionKey"; + string? partitionKeyPartCountVariable = null; if (partitionKeyParts.Count > 1) { - WriteSplitImplementation(writer, partitionKeyParts, "partitionKey", out string partitionKeyPartRangesVariable, true); - getPartitionKeyPartInputVariable = i => $"partitionKey[{partitionKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, partitionKeyParts, "partitionKey", out string partitionKeyPartRangesVariable, true, out partitionKeyPartCountVariable); + getPartitionKeyPartInputVariable = indexExpr => $"partitionKey[{partitionKeyPartRangesVariable}[{indexExpr}]]"; } - Func getSortKeyPartInputVariable = static _ => "sortKey"; + Func getSortKeyPartInputVariable = static _ => "sortKey"; + string? sortKeyPartCountVariable = null; if (sortKeyParts.Count > 1) { - WriteSplitImplementation(writer, sortKeyParts, "sortKey", out string sortKeyPartRangesVariable, true); - getSortKeyPartInputVariable = i => $"sortKey[{sortKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, sortKeyParts, "sortKey", out string sortKeyPartRangesVariable, true, out sortKeyPartCountVariable); + getSortKeyPartInputVariable = indexExpr => $"sortKey[{sortKeyPartRangesVariable}[{indexExpr}]]"; } var propertyNameCounts = partitionKeyParts.Concat(sortKeyParts).OfType().GroupBy(p => p.Property.CamelCaseName).ToDictionary(g => g.Key, _ => 0); - WriteParsePropertiesImplementation(writer, partitionKeyParts, getPartitionKeyPartInputVariable, true, propertyNameCounts); - WriteParsePropertiesImplementation(writer, sortKeyParts, getSortKeyPartInputVariable, true, propertyNameCounts); + WriteParsePropertiesImplementation(writer, partitionKeyParts, getPartitionKeyPartInputVariable, true, propertyNameCounts, partitionKeyPartCountVariable); + WriteParsePropertiesImplementation(writer, sortKeyParts, getSortKeyPartInputVariable, true, propertyNameCounts, sortKeyPartCountVariable); writer.WriteLine($"return {WriteConstructor(targetTypeSpec)};"); @@ -280,23 +284,25 @@ public static bool TryParse(ReadOnlySpan partitionKey, ReadOnlySpan WriteLengthCheck(writer, partitionKeyParts, "partitionKey", false); WriteLengthCheck(writer, sortKeyParts, "sortKey", false); - Func getPartitionKeyPartInputVariable = static _ => "partitionKey"; + Func getPartitionKeyPartInputVariable = static _ => "partitionKey"; + string? partitionKeyPartCountVariable = null; if (partitionKeyParts.Count > 1) { - WriteSplitImplementation(writer, partitionKeyParts, "partitionKey", out string partitionKeyPartRangesVariable, false); - getPartitionKeyPartInputVariable = i => $"partitionKey[{partitionKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, partitionKeyParts, "partitionKey", out string partitionKeyPartRangesVariable, false, out partitionKeyPartCountVariable); + getPartitionKeyPartInputVariable = indexExpr => $"partitionKey[{partitionKeyPartRangesVariable}[{indexExpr}]]"; } - Func getSortKeyPartInputVariable = static _ => "sortKey"; + Func getSortKeyPartInputVariable = static _ => "sortKey"; + string? sortKeyPartCountVariable = null; if (sortKeyParts.Count > 1) { - WriteSplitImplementation(writer, sortKeyParts, "sortKey", out string sortKeyPartRangesVariable, false); - getSortKeyPartInputVariable = i => $"sortKey[{sortKeyPartRangesVariable}[{i}]]"; + WriteSplitImplementation(writer, sortKeyParts, "sortKey", out string sortKeyPartRangesVariable, false, out sortKeyPartCountVariable); + getSortKeyPartInputVariable = indexExpr => $"sortKey[{sortKeyPartRangesVariable}[{indexExpr}]]"; } var propertyNameCounts = partitionKeyParts.Concat(sortKeyParts).OfType().GroupBy(p => p.Property.CamelCaseName).ToDictionary(g => g.Key, _ => 0); - WriteParsePropertiesImplementation(writer, partitionKeyParts, getPartitionKeyPartInputVariable, false, propertyNameCounts); - WriteParsePropertiesImplementation(writer, sortKeyParts, getSortKeyPartInputVariable, false, propertyNameCounts); + WriteParsePropertiesImplementation(writer, partitionKeyParts, getPartitionKeyPartInputVariable, false, propertyNameCounts, partitionKeyPartCountVariable); + WriteParsePropertiesImplementation(writer, sortKeyParts, getSortKeyPartInputVariable, false, propertyNameCounts, sortKeyPartCountVariable); writer.WriteLines($""" result = {WriteConstructor(targetTypeSpec)}; @@ -340,45 +346,103 @@ private static void WriteLengthCheck(SourceWriter writer, List parts, s """); } - private static void WriteSplitImplementation(SourceWriter writer, List parts, string inputName, out string partRangesVariableName, bool shouldThrow) + private static void WriteSplitImplementation(SourceWriter writer, List parts, string inputName, out string partRangesVariableName, bool shouldThrow, out string? partCountVariableName) { + var repeatingPart = parts.OfType().FirstOrDefault(); var uniqueDelimiters = parts.OfType().Select(d => d.Value).Distinct().ToList(); - int expectedParts = parts.OfType().Count(); - (string method, string delimiters) = uniqueDelimiters switch + partRangesVariableName = $"{inputName}PartRanges"; + partCountVariableName = null; + + if (repeatingPart is not null) { - { Count: 1 } => ("Split", $"'{uniqueDelimiters[0]}'"), - { Count: > 1 } => ("SplitAny", $"\"{string.Join(string.Empty, uniqueDelimiters)}\""), - _ => throw new InvalidOperationException() - }; + bool sameSeparator = uniqueDelimiters.Contains(repeatingPart.Separator); - string expectedPartsVariableName = $"expected{inputName.FirstToUpperInvariant()}Parts"; - partRangesVariableName = $"{inputName}PartRanges"; + if (sameSeparator) + { + // Same separator as key delimiters: split produces variable number of parts + int fixedValueParts = parts.OfType().Count(p => p is not RepeatingPropertyKeyPart); - writer.WriteLines($""" - const int {expectedPartsVariableName} = {expectedParts}; - Span {partRangesVariableName} = stackalloc Range[{expectedPartsVariableName} + 1]; - if ({inputName}.{method}({partRangesVariableName}, {delimiters}, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) != {expectedPartsVariableName}) - {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + (string method, string delimiters) = uniqueDelimiters switch + { + { Count: 1 } => ("Split", $"'{uniqueDelimiters[0]}'"), + { Count: > 1 } => ("SplitAny", $"\"{string.Join(string.Empty, uniqueDelimiters)}\""), + _ => throw new InvalidOperationException() + }; - """); + string minPartsVariable = $"minExpected{inputName.FirstToUpperInvariant()}Parts"; + partCountVariableName = $"{inputName}PartCount"; + + writer.WriteLines($""" + const int {minPartsVariable} = {fixedValueParts + 1}; + Span {partRangesVariableName} = stackalloc Range[128]; + int {partCountVariableName} = {inputName}.{method}({partRangesVariableName}, {delimiters}, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if ({partCountVariableName} < {minPartsVariable}) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + + """); + } + else + { + // Different separator: split by fixed delimiters, last part contains the repeating section + int expectedParts = parts.OfType().Count(); + + (string method, string delimiters) = uniqueDelimiters switch + { + { Count: 1 } => ("Split", $"'{uniqueDelimiters[0]}'"), + { Count: > 1 } => ("SplitAny", $"\"{string.Join(string.Empty, uniqueDelimiters)}\""), + _ => throw new InvalidOperationException() + }; + + string expectedPartsVariableName = $"expected{inputName.FirstToUpperInvariant()}Parts"; + + writer.WriteLines($""" + const int {expectedPartsVariableName} = {expectedParts}; + Span {partRangesVariableName} = stackalloc Range[{expectedPartsVariableName} + 1]; + if ({inputName}.{method}({partRangesVariableName}, {delimiters}, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) != {expectedPartsVariableName}) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + + """); + } + } + else + { + int expectedParts = parts.OfType().Count(); + + (string method, string delimiters) = uniqueDelimiters switch + { + { Count: 1 } => ("Split", $"'{uniqueDelimiters[0]}'"), + { Count: > 1 } => ("SplitAny", $"\"{string.Join(string.Empty, uniqueDelimiters)}\""), + _ => throw new InvalidOperationException() + }; + + string expectedPartsVariableName = $"expected{inputName.FirstToUpperInvariant()}Parts"; + + writer.WriteLines($""" + const int {expectedPartsVariableName} = {expectedParts}; + Span {partRangesVariableName} = stackalloc Range[{expectedPartsVariableName} + 1]; + if ({inputName}.{method}({partRangesVariableName}, {delimiters}, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) != {expectedPartsVariableName}) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + + """); + } } private static void WriteParsePropertiesImplementation( - SourceWriter writer, List parts, Func getPartInputVariable, bool shouldThrow) + SourceWriter writer, List parts, Func getPartInputVariable, bool shouldThrow, string? inputPartCountVariable = null) { var propertyNameCounts = parts.OfType().GroupBy(p => p.Property.CamelCaseName).ToDictionary(g => g.Key, _ => 0); - WriteParsePropertiesImplementation(writer, parts, getPartInputVariable, shouldThrow, propertyNameCounts); + WriteParsePropertiesImplementation(writer, parts, getPartInputVariable, shouldThrow, propertyNameCounts, inputPartCountVariable); } private static void WriteParsePropertiesImplementation( - SourceWriter writer, List parts, Func getPartInputVariable, bool shouldThrow, Dictionary propertyNameCounts) + SourceWriter writer, List parts, Func getPartInputVariable, bool shouldThrow, Dictionary propertyNameCounts, string? inputPartCountVariable = null) { var valueParts = parts.OfType().ToArray(); for (int i = 0; i < valueParts.Length; i++) { var valueKeyPart = valueParts[i]; - string partInputVariable = getPartInputVariable(i); + string partInputVariable = getPartInputVariable($"{i}"); if (valueKeyPart is ConstantKeyPart c) { @@ -390,6 +454,12 @@ private static void WriteParsePropertiesImplementation( continue; } + if (valueKeyPart is RepeatingPropertyKeyPart repeatingPart) + { + WriteRepeatingPropertyParse(repeatingPart, i); + continue; + } + (string camelCaseName, string? originalCamelCaseName) = valueKeyPart is PropertyKeyPart propertyPart ? GetCamelCaseName(propertyPart.Property, propertyNameCounts) : throw new InvalidOperationException($"Expected a {nameof(PropertyKeyPart)} but got a {valueKeyPart.GetType().Name}"); @@ -456,6 +526,108 @@ private static void WriteParsePropertiesImplementation( static string ToStrictLengthCheck(KeyPart part, string input) => part.ExactLengthRequirement ? $"{input}.Length != {part.LengthRequired} || " : string.Empty; + + void WriteRepeatingPropertyParse(RepeatingPropertyKeyPart repeatingPart, int valuePartIndex) + { + string camelCaseName = repeatingPart.Property.CamelCaseName; + string innerTypeName = repeatingPart.InnerType.FullyQualifiedName; + var uniqueDelimiters = parts.OfType().Select(d => d.Value).Distinct().ToList(); + bool sameSeparator = uniqueDelimiters.Contains(repeatingPart.Separator); + + string itemVar = $"{camelCaseName}Item"; + string listVar = camelCaseName; + + if (sameSeparator && inputPartCountVariable is not null) + { + // Same separator: repeating items are at indices valuePartIndex..partCount-1 + writer.WriteLines($""" + var {listVar} = new global::System.Collections.Generic.List<{innerTypeName}>(); + """); + + writer.StartBlock($"for (int ri = {valuePartIndex}; ri < {inputPartCountVariable}; ri++)"); + + string riAccess = getPartInputVariable("ri"); + + WriteRepeatingItemParse(repeatingPart, riAccess, itemVar, listVar); + + writer.EndBlock(); + writer.WriteLine(); + } + else + { + // Different separator: sub-split the part by the repeating separator + string partInputVariable = getPartInputVariable($"{valuePartIndex}"); + string repeatingRangesVar = $"{camelCaseName}Ranges"; + string repeatingCountVar = $"{camelCaseName}Count"; + + writer.WriteLines($""" + Span {repeatingRangesVar} = stackalloc Range[128]; + int {repeatingCountVar} = {partInputVariable}.Split({repeatingRangesVar}, '{repeatingPart.Separator}', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if ({repeatingCountVar} < 1) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + + var {listVar} = new global::System.Collections.Generic.List<{innerTypeName}>(); + """); + + writer.StartBlock($"for (int ri = 0; ri < {repeatingCountVar}; ri++)"); + + string riAccess = $"{partInputVariable}[{repeatingRangesVar}[ri]]"; + WriteRepeatingItemParse(repeatingPart, riAccess, itemVar, listVar); + + writer.EndBlock(); + writer.WriteLine(); + } + + // Validate at least 1 item + writer.WriteLines($""" + if ({listVar}.Count == 0) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + + """); + } + + void WriteRepeatingItemParse(RepeatingPropertyKeyPart repeatingPart, string itemInput, string itemVar, string listVar) + { + string innerTypeName = repeatingPart.InnerType.FullyQualifiedName; + + switch (repeatingPart.InnerParseType) + { + case ParseType.Guid: + writer.WriteLines($""" + if (!Guid.TryParseExact({itemInput}, "{repeatingPart.Format}", out var {itemVar})) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + {listVar}.Add({itemVar}); + """); + break; + + case ParseType.String: + writer.WriteLines($""" + if ({itemInput}.Length == 0) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + {listVar}.Add({itemInput}.ToString()); + """); + break; + + case ParseType.Enum: + if (repeatingPart.Property.EnumSpec is null) + throw new InvalidOperationException($"{nameof(repeatingPart.Property.EnumSpec)} is null"); + + writer.WriteLines($""" + if (!{repeatingPart.Property.EnumSpec.Name}Helper.TryParse({itemInput}, out var {itemVar})) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + {listVar}.Add({itemVar}); + """); + break; + + case ParseType.SpanParsable: + writer.WriteLines($""" + if (!{innerTypeName}.TryParse({itemInput}, out var {itemVar})) + {(shouldThrow ? "throw new FormatException(\"Unrecognized format.\")" : "return false")}; + {listVar}.Add({itemVar}); + """); + break; + } + } } private static string WriteConstructor(TargetTypeSpec targetTypeSpec) @@ -466,7 +638,12 @@ private static string WriteConstructor(TargetTypeSpec targetTypeSpec) if (targetTypeSpec.ConstructorParameters.Count > 0) { foreach (var parameter in targetTypeSpec.ConstructorParameters) - builder.Append($"{parameter.CamelCaseName}, "); + { + var property = targetTypeSpec.Properties.FirstOrDefault(p => p.CamelCaseName == parameter.CamelCaseName); + builder.Append(property?.CollectionType == CollectionType.ImmutableArray + ? $"global::System.Collections.Immutable.ImmutableArray.CreateRange({parameter.CamelCaseName}), " + : $"{parameter.CamelCaseName}, "); + } builder.Length -= 2; // Remove the last ", " } @@ -478,7 +655,12 @@ private static string WriteConstructor(TargetTypeSpec targetTypeSpec) builder.Append(" { "); foreach (var initializer in targetTypeSpec.PropertyInitializers) - builder.Append($"{initializer.Name} = {initializer.CamelCaseName}, "); + { + var property = targetTypeSpec.Properties.FirstOrDefault(p => p.CamelCaseName == initializer.CamelCaseName); + builder.Append(property?.CollectionType == CollectionType.ImmutableArray + ? $"{initializer.Name} = global::System.Collections.Immutable.ImmutableArray.CreateRange({initializer.CamelCaseName}), " + : $"{initializer.Name} = {initializer.CamelCaseName}, "); + } builder.Length -= 2; // Remove the last ", " builder.Append(" }"); @@ -492,7 +674,13 @@ private static void WriteFormatMethodBodyForKeyParts( { writer.StartBlock(methodDeclaration); - if (keyParts.All(kp => kp is + bool hasRepeatingPart = keyParts.Any(kp => kp is RepeatingPropertyKeyPart); + + if (hasRepeatingPart) + { + WriteRepeatingFormatBody(); + } + else if (keyParts.All(kp => kp is DelimiterKeyPart or ConstantKeyPart or PropertyKeyPart { FormatType: FormatType.Guid, ExactLengthRequirement: true } @@ -582,11 +770,210 @@ or ConstantKeyPart writer.EndBlock(); writer.WriteLine(); + return; + static string GetCharsWritten(PropertySpec p) => $"{p.CamelCaseName}CharsWritten"; + + void WriteRepeatingFormatBody() + { + // Emit empty collection checks for all repeating parts + foreach (var keyPart in keyParts.OfType()) + { + string countExpression = GetRepeatingCountExpression(keyPart.Property); + + writer.WriteLines($""" + if ({countExpression} == 0) + throw new FormatException("Collection must contain at least one item."); + + """); + } + + // Count fixed literal lengths and variable parts for DefaultInterpolatedStringHandler + int fixedLiteralLength = 0; + int formattedCount = 0; + foreach (var keyPart in keyParts) + { + switch (keyPart) + { + case DelimiterKeyPart: + fixedLiteralLength += 1; + break; + case ConstantKeyPart c: + fixedLiteralLength += c.Value.Length; + break; + case PropertyKeyPart: + formattedCount++; + break; + case RepeatingPropertyKeyPart: + // Will be handled dynamically in the loop + break; + } + } + + string formatProvider = invariantFormatting ? InvariantCulture : "null"; + + writer.WriteLines($""" + var handler = new System.Runtime.CompilerServices.DefaultInterpolatedStringHandler({fixedLiteralLength}, {formattedCount}, {formatProvider}); + """); + + foreach (var keyPart in keyParts) + { + switch (keyPart) + { + case DelimiterKeyPart d: + writer.WriteLine($"handler.AppendLiteral(\"{d.Value}\");"); + break; + case ConstantKeyPart c: + writer.WriteLine($"handler.AppendLiteral(\"{c.Value}\");"); + break; + case PropertyKeyPart p: + if (p.Format is not null) + writer.WriteLine($"handler.AppendFormatted({p.Property.Name}, \"{p.Format}\");"); + else + writer.WriteLine($"handler.AppendFormatted({p.Property.Name});"); + break; + case RepeatingPropertyKeyPart rp: + WriteRepeatingPartFormatLoop(rp); + break; + } + } + + writer.WriteLine(); + writer.WriteLine("return handler.ToStringAndClear();"); + } + + void WriteRepeatingPartFormatLoop(RepeatingPropertyKeyPart rp) + { + string countExpression = GetRepeatingCountExpression(rp.Property); + + writer.StartBlock($"for (int i = 0; i < {countExpression}; i++)"); + + writer.WriteLines($""" + if (i > 0) + handler.AppendLiteral("{rp.Separator}"); + + """); + + if (rp.Format is not null) + writer.WriteLine($"handler.AppendFormatted({rp.Property.Name}[i], \"{rp.Format}\");"); + else + writer.WriteLine($"handler.AppendFormatted({rp.Property.Name}[i]);"); + + writer.EndBlock(); + } } private static void WriteDynamicFormatMethodBodyForKeyParts( SourceWriter writer, string methodDeclaration, IReadOnlyList keyParts, bool invariantFormatting) + { + var repeatingPart = keyParts.OfType().FirstOrDefault(); + + if (repeatingPart is null) + { + WriteDynamicFormatMethodBodyForFixedKeyParts(writer, methodDeclaration, keyParts, invariantFormatting); + return; + } + + // Find the index of the repeating part and count fixed value parts before it + int repeatingKeyPartIndex = keyParts.ToList().IndexOf(repeatingPart); + int fixedPartCount = keyParts.Take(repeatingKeyPartIndex).OfType().Count(); + var fixedKeyParts = keyParts.Take(repeatingKeyPartIndex).ToList(); + + writer.StartBlock(methodDeclaration); + + WriteFixedPartCases(); + WriteRepeatingPartHandler(); + + writer.EndBlock(); // end method + writer.WriteLine(); + + return; + + void WriteFixedPartCases() + { + if (fixedKeyParts.Count == 0) + return; + + writer.StartBlock("switch (throughPartIndex, includeTrailingDelimiter)"); + + for (int i = 0, keyPartIndex = -1; i < fixedKeyParts.Count; i++) + { + var keyPart = fixedKeyParts[i]; + + bool isDelimiter = keyPart is DelimiterKeyPart; + if (!isDelimiter) + keyPartIndex++; + + string switchCase = $"case ({keyPartIndex}, {(isDelimiter ? "true" : "false")}):"; + string formatString = BuildFormatStringForKeyParts(fixedKeyParts.Take(i + 1)); + + writer.WriteLine(invariantFormatting + ? $"{switchCase} return string.Create({InvariantCulture}, $\"{formatString}\");" + : $"{switchCase} return $\"{formatString}\";"); + } + + writer.EndBlock(); + writer.WriteLine(); + } + + void WriteRepeatingPartHandler() + { + string propName = repeatingPart.Property.Name; + char separator = repeatingPart.Separator; + string? format = repeatingPart.Format; + string countExpression = GetRepeatingCountExpression(repeatingPart.Property); + + writer.WriteLines($""" + int fixedPartCount = {fixedPartCount}; + int repeatIndex = throughPartIndex - fixedPartCount; + int repeatCount = Math.Min(repeatIndex + 1, {countExpression}); + if (repeatCount <= 0) + throw new InvalidOperationException("Invalid throughPartIndex for repeating section."); + + """); + + string fixedPrefix = BuildFormatStringForKeyParts(fixedKeyParts); + + writer.WriteLines($$""" + var handler = new System.Runtime.CompilerServices.DefaultInterpolatedStringHandler(0, 0{{(invariantFormatting ? $", {InvariantCulture}" : "")}}); + """); + + if (fixedPrefix.Length > 0) + { + writer.WriteLine(invariantFormatting + ? $"handler.AppendFormatted(string.Create({InvariantCulture}, $\"{fixedPrefix}\"));" + : $"handler.AppendFormatted($\"{fixedPrefix}\");"); + } + + writer.WriteLine(); + + writer.StartBlock("for (int i = 0; i < repeatCount; i++)"); + + writer.StartBlock("if (i > 0)"); + writer.WriteLine($"handler.AppendLiteral(\"{separator}\");"); + writer.EndBlock(); + + writer.WriteLine(); + + if (format is not null) + writer.WriteLine($"handler.AppendFormatted({propName}[i], \"{format}\");"); + else + writer.WriteLine($"handler.AppendFormatted({propName}[i]);"); + + writer.EndBlock(); // end for loop + writer.WriteLine(); + + writer.StartBlock("if (includeTrailingDelimiter)"); + writer.WriteLine($"handler.AppendLiteral(\"{separator}\");"); + writer.EndBlock(); + writer.WriteLine(); + + writer.WriteLine("return handler.ToStringAndClear();"); + } + } + + private static void WriteDynamicFormatMethodBodyForFixedKeyParts( + SourceWriter writer, string methodDeclaration, IReadOnlyList keyParts, bool invariantFormatting) { writer.StartBlock(methodDeclaration); @@ -616,6 +1003,11 @@ private static void WriteDynamicFormatMethodBodyForKeyParts( writer.WriteLine(); } + private static string GetRepeatingCountExpression(PropertySpec property) => + property.CollectionType == CollectionType.ImmutableArray + ? $"{property.Name}.Length" + : $"{property.Name}.Count"; + private static string BuildFormatStringForKeyParts(IEnumerable keyParts) { var builder = new StringBuilder(); diff --git a/src/CompositeKey.SourceGeneration/SourceGenerator.Parser.cs b/src/CompositeKey.SourceGeneration/SourceGenerator.Parser.cs index b26dd41..3dfea78 100644 --- a/src/CompositeKey.SourceGeneration/SourceGenerator.Parser.cs +++ b/src/CompositeKey.SourceGeneration/SourceGenerator.Parser.cs @@ -168,6 +168,7 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl PrimaryDelimiterTemplateToken pd => new PrimaryDelimiterKeyPart(pd.Value) { LengthRequired = 1 }, DelimiterTemplateToken d => new DelimiterKeyPart(d.Value) { LengthRequired = 1 }, PropertyTemplateToken p => ToPropertyKeyPart(p), + RepeatingPropertyTemplateToken rp => ToRepeatingPropertyKeyPart(rp), ConstantTemplateToken c => new ConstantKeyPart(c.Value) { LengthRequired = c.Value.Length }, _ => null }; @@ -181,6 +182,51 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl keyParts.Add(keyPart); } + // Validate: repeating type used without repeating syntax -> COMPOSITE0010 + foreach (var keyPart in keyParts) + { + if (keyPart is PropertyKeyPart pkp && pkp.Property.CollectionType != CollectionType.None) + { + ReportDiagnostic(DiagnosticDescriptors.RepeatingTypeMustUseRepeatingSyntax, _location, pkp.Property.Name); + return null; + } + } + + // Validate: repeating property must be the last value part in its key section -> COMPOSITE0011 + var valueParts = keyParts.Where(kp => kp is ValueKeyPart).ToList(); + if (valueParts.Count > 0 && valueParts[^1] is not RepeatingPropertyKeyPart) + { + // Only report if there's a repeating part that isn't last + if (valueParts.Any(kp => kp is RepeatingPropertyKeyPart)) + { + var repeatingPart = valueParts.First(kp => kp is RepeatingPropertyKeyPart) as RepeatingPropertyKeyPart; + ReportDiagnostic(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, _location, repeatingPart!.Property.Name); + return null; + } + } + + // For composite keys, also validate repeating position within each section + if (keyParts.Any(kp => kp is PrimaryDelimiterKeyPart)) + { + int delimiterIndex = keyParts.FindIndex(kp => kp is PrimaryDelimiterKeyPart); + + var partitionValueParts = keyParts.Take(delimiterIndex).Where(kp => kp is ValueKeyPart).ToList(); + if (partitionValueParts.Count > 0 && partitionValueParts.Any(kp => kp is RepeatingPropertyKeyPart) && partitionValueParts[^1] is not RepeatingPropertyKeyPart) + { + var repeatingPart = partitionValueParts.First(kp => kp is RepeatingPropertyKeyPart) as RepeatingPropertyKeyPart; + ReportDiagnostic(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, _location, repeatingPart!.Property.Name); + return null; + } + + var sortValueParts = keyParts.Skip(delimiterIndex + 1).Where(kp => kp is ValueKeyPart).ToList(); + if (sortValueParts.Count > 0 && sortValueParts.Any(kp => kp is RepeatingPropertyKeyPart) && sortValueParts[^1] is not RepeatingPropertyKeyPart) + { + var repeatingPart = sortValueParts.First(kp => kp is RepeatingPropertyKeyPart) as RepeatingPropertyKeyPart; + ReportDiagnostic(DiagnosticDescriptors.RepeatingPropertyMustBeLastPart, _location, repeatingPart!.Property.Name); + return null; + } + } + return keyParts; PropertyKeyPart? ToPropertyKeyPart(PropertyTemplateToken templateToken) @@ -201,6 +247,13 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl propertiesUsedInKey.Add(property); var (propertySpec, typeSymbol) = property; + // Repeating type properties must use repeating syntax + if (propertySpec.CollectionType != CollectionType.None) + { + ReportDiagnostic(DiagnosticDescriptors.RepeatingTypeMustUseRepeatingSyntax, _location, propertySpec.Name); + return null; + } + var interfaces = typeSymbol.AllInterfaces; bool isSpanParsable = interfaces.Any(i => i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).StartsWith("global::System.ISpanParsable")); bool isSpanFormattable = interfaces.Any(i => i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Equals("global::System.ISpanFormattable")); @@ -271,6 +324,102 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl ExactLengthRequirement = exactLengthRequirement }; } + + RepeatingPropertyKeyPart? ToRepeatingPropertyKeyPart(RepeatingPropertyTemplateToken templateToken) + { + var availableProperties = properties + .Select(p => new TemplateValidation.PropertyInfo(p.Spec.Name, p.Spec.HasGetter, p.Spec.HasSetter)) + .ToList(); + + var propertyValidation = TemplateValidation.ValidatePropertyReferences([templateToken], availableProperties); + if (!propertyValidation.IsSuccess) + { + ReportDiagnostic(propertyValidation.Descriptor, _location, propertyValidation.MessageArgs); + return null; + } + + var property = properties.First(p => p.Spec.Name == templateToken.Name); + var (propertySpec, typeSymbol) = property; + + // Validate that the property is a collection type + if (propertySpec.CollectionType == CollectionType.None) + { + ReportDiagnostic(DiagnosticDescriptors.RepeatingPropertyMustUseCollectionType, _location, propertySpec.Name); + return null; + } + + // Extract inner type from the collection + var namedTypeSymbol = (INamedTypeSymbol)typeSymbol; + var innerTypeSymbol = namedTypeSymbol.TypeArguments[0]; + + var innerInterfaces = innerTypeSymbol.AllInterfaces; + bool isSpanParsable = innerInterfaces.Any(i => i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).StartsWith("global::System.ISpanParsable")); + bool isSpanFormattable = innerInterfaces.Any(i => i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Equals("global::System.ISpanFormattable")); + + var innerTypeInfo = new PropertyValidation.PropertyTypeInfo( + TypeName: innerTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + IsGuid: SymbolEqualityComparer.Default.Equals(innerTypeSymbol, _knownTypeSymbols.GuidType), + IsString: SymbolEqualityComparer.Default.Equals(innerTypeSymbol, _knownTypeSymbols.StringType), + IsEnum: innerTypeSymbol.TypeKind == TypeKind.Enum, + IsSpanParsable: isSpanParsable, + IsSpanFormattable: isSpanFormattable); + + var formatValidation = PropertyValidation.ValidatePropertyFormat( + propertySpec.Name, + innerTypeInfo, + templateToken.Format); + + if (!formatValidation.IsSuccess) + { + ReportDiagnostic(formatValidation.Descriptor, _location, formatValidation.MessageArgs); + return null; + } + + var typeCompatibility = PropertyValidation.ValidatePropertyTypeCompatibility( + propertySpec.Name, + innerTypeInfo); + + if (!typeCompatibility.IsSuccess) + { + throw new NotSupportedException($"Unsupported inner type '{innerTypeInfo.TypeName}' for repeating property '{propertySpec.Name}'"); + } + + propertiesUsedInKey.Add(property); + + ParseType innerParseType; + FormatType innerFormatType; + string? format = templateToken.Format; + + if (innerTypeInfo.IsGuid) + { + innerParseType = ParseType.Guid; + innerFormatType = FormatType.Guid; + format = templateToken.Format?.ToLowerInvariant() ?? "d"; + } + else if (innerTypeInfo.IsString) + { + innerParseType = ParseType.String; + innerFormatType = FormatType.String; + format = null; + } + else if (innerTypeInfo.IsEnum) + { + innerParseType = ParseType.Enum; + innerFormatType = FormatType.Enum; + format = templateToken.Format?.ToLowerInvariant() ?? "g"; + } + else + { + innerParseType = ParseType.SpanParsable; + innerFormatType = FormatType.SpanFormattable; + } + + return new RepeatingPropertyKeyPart(propertySpec, templateToken.Separator, format, innerParseType, innerFormatType, new TypeRef(innerTypeSymbol)) + { + LengthRequired = 1, + ExactLengthRequirement = false + }; + } } private static List? ParsePropertyInitializers( @@ -313,7 +462,7 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl return propertyInitializers; } - private static List<(PropertySpec Spec, ITypeSymbol TypeSymbol)> ParseProperties(INamedTypeSymbol typeSymbol) + private List<(PropertySpec Spec, ITypeSymbol TypeSymbol)> ParseProperties(INamedTypeSymbol typeSymbol) { List<(PropertySpec Spec, ITypeSymbol TypeSymbol)> properties = []; foreach (var propertySymbol in typeSymbol.GetMembers().OfType()) @@ -324,9 +473,34 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl if (propertySymbol.IsStatic || propertySymbol.Parameters.Length > 0) continue; + // Detect collection types + var collectionType = CollectionType.None; + ITypeSymbol effectiveTypeSymbol = propertySymbol.Type; + + if (propertySymbol.Type is INamedTypeSymbol namedType) + { + var originalDefinition = namedType.OriginalDefinition; + if (SymbolEqualityComparer.Default.Equals(originalDefinition, _knownTypeSymbols.ListType)) + collectionType = CollectionType.List; + else if (SymbolEqualityComparer.Default.Equals(originalDefinition, _knownTypeSymbols.ReadOnlyListType)) + collectionType = CollectionType.IReadOnlyList; + else if (SymbolEqualityComparer.Default.Equals(originalDefinition, _knownTypeSymbols.ImmutableArrayType)) + collectionType = CollectionType.ImmutableArray; + } + + // For collection types, extract inner type for EnumSpec EnumSpec? enumSpec = null; - if (propertySymbol.Type is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) - enumSpec = ExtractEnumDefinition(enumType); + if (collectionType != CollectionType.None) + { + var innerType = ((INamedTypeSymbol)propertySymbol.Type).TypeArguments[0]; + if (innerType is INamedTypeSymbol { TypeKind: TypeKind.Enum } innerEnumType) + enumSpec = ExtractEnumDefinition(innerEnumType); + } + else + { + if (propertySymbol.Type is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + enumSpec = ExtractEnumDefinition(enumType); + } var propertySpec = new PropertySpec( new TypeRef(propertySymbol.Type), @@ -336,7 +510,8 @@ private static (List PartitionKeyParts, List SortKeyParts) Spl propertySymbol.GetMethod is not null, propertySymbol.SetMethod is not null, propertySymbol.SetMethod is { IsInitOnly: true }, - enumSpec); + enumSpec, + collectionType); properties.Add((propertySpec, propertySymbol.Type)); }