diff --git a/docs/dates.md b/docs/dates.md index fb458fb98c..a3b17e3c59 100644 --- a/docs/dates.md +++ b/docs/dates.md @@ -29,7 +29,7 @@ var target = new DateTimeTarget await Verify(target); ``` -snippet source | anchor +snippet source | anchor Results in the following: @@ -70,7 +70,7 @@ settings.DontScrubDateTimes(); return Verify(target, settings); ``` -snippet source | anchor +snippet source | anchor @@ -87,7 +87,7 @@ var target = new return Verify(target) .DontScrubDateTimes(); ``` -snippet source | anchor +snippet source | anchor @@ -100,7 +100,7 @@ return Verify(target) public static void ModuleInitializer() => VerifierSettings.DontScrubDateTimes(); ``` -snippet source | anchor +snippet source | anchor @@ -124,7 +124,7 @@ settings.DisableDateCounting(); return Verify(target, settings); ``` -snippet source | anchor +snippet source | anchor @@ -141,7 +141,7 @@ var target = new return Verify(target) .DisableDateCounting(); ``` -snippet source | anchor +snippet source | anchor @@ -154,7 +154,7 @@ return Verify(target) public static void ModuleInitializer() => VerifierSettings.DisableDateCounting(); ``` -snippet source | anchor +snippet source | anchor @@ -201,7 +201,7 @@ public Task ScrubInlineDateTimesInstance() settings); } ``` -snippet source | anchor +snippet source | anchor @@ -215,7 +215,7 @@ public Task ScrubInlineDateTimesFluent() => Verify("content 2020-10-20 content") .ScrubInlineDateTimes("yyyy-MM-dd"); ``` -snippet source | anchor +snippet source | anchor @@ -231,7 +231,7 @@ public static class ModuleInitializer VerifierSettings.ScrubInlineDateTimes("yyyy-MM-dd"); } ``` -snippet source | anchor +snippet source | anchor @@ -252,7 +252,7 @@ settings.AddNamedDateTime(new(2030, 1, 2), "instanceNamedDateTime"); settings.AddNamedDateTimeOffset(new DateTime(2030, 1, 2), "instanceNamedTimeOffset"); await Verify(target, settings); ``` -snippet source | anchor +snippet source | anchor @@ -267,7 +267,7 @@ await Verify(target) .AddNamedDateTime(new(2030, 1, 2), "instanceNamedDateTime") .AddNamedDateTimeOffset(new DateTime(2030, 1, 2), "instanceNamedTimeOffset"); ``` -snippet source | anchor +snippet source | anchor @@ -285,7 +285,7 @@ public static void NamedDatesAndTimesGlobal() VerifierSettings.AddNamedDateTimeOffset(new(new(2030, 1, 1)), "namedDateTimeOffset"); } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/guids.md b/docs/guids.md index b3e05eb398..526743de76 100644 --- a/docs/guids.md +++ b/docs/guids.md @@ -23,7 +23,7 @@ var target = new GuidTarget await Verify(target); ``` -snippet source | anchor +snippet source | anchor Results in the following: @@ -79,7 +79,7 @@ await Verify(target) ```cs VerifierSettings.DontScrubGuids(); ``` -snippet source | anchor +snippet source | anchor @@ -103,7 +103,7 @@ public Task ScrubInlineGuidsInstance() settings); } ``` -snippet source | anchor +snippet source | anchor @@ -117,7 +117,7 @@ public Task ScrubInlineGuidsFluent() => Verify("content 651ad409-fc30-4b12-a47e-616d3f953e4c content") .ScrubInlineGuids(); ``` -snippet source | anchor +snippet source | anchor @@ -133,7 +133,7 @@ public static class ModuleInitializer VerifierSettings.ScrubInlineGuids(); } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/mdsource/numeric-ids.source.md b/docs/mdsource/numeric-ids.source.md index 44b6848fe9..4bcdf4b48d 100644 --- a/docs/mdsource/numeric-ids.source.md +++ b/docs/mdsource/numeric-ids.source.md @@ -1,9 +1,57 @@ # Numeric Ids -Numbers are not scrubbed. Sometimes it is helpful to scrub numeric Ids. This can be done using `ScrubMembers` and checking the DeclaringType and the name of the member. + +## ScrubNumericIds + +Opt in scrubbing of numeric properties ending in `Id` or `ID`. Each unique numeric value gets a stable counter based replacement, similar to [Guid](guids.md) and [Date](dates.md) scrubbing. + +The counter is scoped per property name. For properties named `Id`, the declaring type name is used as the scope (e.g. `Customer_1`). For properties like `CustomerId` or `OrderId`, the full property name is the scope (e.g. `CustomerId_1`, `OrderId_1`). This ensures stable output regardless of the actual numeric values, which is particularly useful when working with auto-incrementing database ids. + + +### Fluent + +snippet: ScrubNumericIdsFluent + +Results in the following: + +snippet: SerializationTests.ScrubNumericIdsFluent.verified.txt + + +### Instance + +snippet: ScrubNumericIdsInstance + + +### Globally + +snippet: ScrubNumericIdsGlobal + + +### Parent-child relationships + +When verifying object graphs with parent-child relationships, each id property gets its own counter scope. Properties named `Id` use the declaring type as the scope, while foreign key properties like `CustomerId` and `OrderId` use the property name. + +snippet: ScrubNumericIdsRelationships + +Results in the following: + +snippet: SerializationTests.ScrubNumericIdsNamedType.verified.txt + +Note: + + * `Id` on `Customer` produces `Customer_1`, `Customer_2` + * `Id` on `Order` produces `Order_1`, `Order_2` + * `Id` on `OrderItem` produces `OrderItem_1`, `OrderItem_2`, `OrderItem_3` + * `ProductId` is scoped independently, so the same product (id 7) is `ProductId_1` in both orders + * `Quantity` is not scrubbed since it does not end in `Id` + + +## ScrubMembers approach + +For more targeted control, `ScrubMembers` can be used to check the DeclaringType and the name of the member. snippet: NumericIdSample Produces -snippet: NumericIdSample.Test.verified.txt \ No newline at end of file +snippet: NumericIdSample.Test.verified.txt diff --git a/docs/members-throw.md b/docs/members-throw.md index 820cf8c72b..1324480da5 100644 --- a/docs/members-throw.md +++ b/docs/members-throw.md @@ -35,7 +35,7 @@ public Task CustomExceptionPropFluent() .IgnoreMembersThatThrow(); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -45,7 +45,7 @@ Or globally: ```cs VerifierSettings.IgnoreMembersThatThrow(); ``` -snippet source | anchor +snippet source | anchor Result: @@ -82,7 +82,7 @@ public Task ExceptionMessagePropFluent() .IgnoreMembersThatThrow(_ => _.Message == "Ignore"); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -92,7 +92,7 @@ Or globally: ```cs VerifierSettings.IgnoreMembersThatThrow(_ => _.Message == "Ignore"); ``` -snippet source | anchor +snippet source | anchor Result: diff --git a/docs/named-tuples.md b/docs/named-tuples.md index 252c06df19..2e36544341 100644 --- a/docs/named-tuples.md +++ b/docs/named-tuples.md @@ -19,7 +19,7 @@ Given a method that returns a named tuple: static (bool Member1, string Member2, string Member3) MethodWithNamedTuple() => (true, "A", "B"); ``` -snippet source | anchor +snippet source | anchor Can be verified: @@ -29,7 +29,7 @@ Can be verified: ```cs await VerifyTuple(() => MethodWithNamedTuple()); ``` -snippet source | anchor +snippet source | anchor Resulting in: diff --git a/docs/numeric-ids.md b/docs/numeric-ids.md index 92ab4cd10a..700e1c67af 100644 --- a/docs/numeric-ids.md +++ b/docs/numeric-ids.md @@ -7,7 +7,240 @@ To change this file edit the source file and then run MarkdownSnippets. # Numeric Ids -Numbers are not scrubbed. Sometimes it is helpful to scrub numeric Ids. This can be done using `ScrubMembers` and checking the DeclaringType and the name of the member. + +## ScrubNumericIds + +Opt in scrubbing of numeric properties ending in `Id` or `ID`. Each unique numeric value gets a stable counter based replacement, similar to [Guid](guids.md) and [Date](dates.md) scrubbing. + +The counter is scoped per property name. For properties named `Id`, the declaring type name is used as the scope (e.g. `Customer_1`). For properties like `CustomerId` or `OrderId`, the full property name is the scope (e.g. `CustomerId_1`, `OrderId_1`). This ensures stable output regardless of the actual numeric values, which is particularly useful when working with auto-incrementing database ids. + + +### Fluent + + + +```cs +var target = new +{ + Id = 123, + UserId = 456, + userID = 789, + Name = "Test", + Count = 10 +}; +return Verify(target) + .ScrubNumericIds(); +``` +snippet source | anchor + + +Results in the following: + + + +```txt +{ + Id: Id_1, + UserId: UserId_1, + userID: userID_1, + Name: Test, + Count: 10 +} +``` +snippet source | anchor + + + +### Instance + + + +```cs +var target = new +{ + Id = 123, + UserId = 456, + Name = "Test" +}; +var settings = new VerifySettings(); +settings.ScrubNumericIds(); +return Verify(target, settings); +``` +snippet source | anchor + + + +### Globally + + + +```cs +VerifierSettings.ScrubNumericIds(); +``` +snippet source | anchor + + + +### Parent-child relationships + +When verifying object graphs with parent-child relationships, each id property gets its own counter scope. Properties named `Id` use the declaring type as the scope, while foreign key properties like `CustomerId` and `OrderId` use the property name. + + + +```cs +public class Customer +{ + public int Id; + public string? Name; + public List Orders = []; +} + +public class Order +{ + public int Id; + public int CustomerId; + public List Items = []; +} + +public class OrderItem +{ + public long Id; + public int OrderId; + public int ProductId; + public int Quantity; +} + +[Fact] +public Task ScrubNumericIdsNamedType() +{ + var target = new List + { + new() + { + Id = 1023, + Name = "Alice", + Orders = + [ + new() + { + Id = 5001, + CustomerId = 1023, + Items = + [ + new() + { + Id = 90_001, + OrderId = 5001, + ProductId = 7, + Quantity = 2 + }, + new() + { + Id = 90_002, + OrderId = 5001, + ProductId = 12, + Quantity = 1 + } + ] + } + ] + }, + new() + { + Id = 1099, + Name = "Bob", + Orders = + [ + new() + { + Id = 5002, + CustomerId = 1099, + Items = + [ + new() + { + Id = 90_003, + OrderId = 5002, + ProductId = 7, + Quantity = 5 + } + ] + } + ] + } + }; + return Verify(target) + .ScrubNumericIds(); +} +``` +snippet source | anchor + + +Results in the following: + + + +```txt +[ + { + Id: Customer_1, + Name: Alice, + Orders: [ + { + Id: Order_1, + CustomerId: CustomerId_1, + Items: [ + { + Id: OrderItem_1, + OrderId: OrderId_1, + ProductId: ProductId_1, + Quantity: 2 + }, + { + Id: OrderItem_2, + OrderId: OrderId_1, + ProductId: ProductId_2, + Quantity: 1 + } + ] + } + ] + }, + { + Id: Customer_2, + Name: Bob, + Orders: [ + { + Id: Order_2, + CustomerId: CustomerId_2, + Items: [ + { + Id: OrderItem_3, + OrderId: OrderId_2, + ProductId: ProductId_1, + Quantity: 5 + } + ] + } + ] + } +] +``` +snippet source | anchor + + +Note: + + * `Id` on `Customer` produces `Customer_1`, `Customer_2` + * `Id` on `Order` produces `Order_1`, `Order_2` + * `Id` on `OrderItem` produces `OrderItem_1`, `OrderItem_2`, `OrderItem_3` + * `ProductId` is scoped independently, so the same product (id 7) is `ProductId_1` in both orders + * `Quantity` is not scrubbed since it does not end in `Id` + + +## ScrubMembers approach + +For more targeted control, `ScrubMembers` can be used to check the DeclaringType and the name of the member. @@ -31,7 +264,7 @@ public class NumericIdSample { var target = new Target { - Id = new Random().Next(), + Id = Random.Shared.Next(), Name = "The Name" }; return Verify(target); diff --git a/docs/obsolete-members.md b/docs/obsolete-members.md index 016093211f..c42a33f815 100644 --- a/docs/obsolete-members.md +++ b/docs/obsolete-members.md @@ -31,7 +31,7 @@ public Task WithObsoleteProp() return Verify(target); } ``` -snippet source | anchor +snippet source | anchor Result: @@ -79,7 +79,7 @@ public Task WithObsoletePropIncludedFluent() .IncludeObsoletes(); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -89,7 +89,7 @@ Or globally: ```cs VerifierSettings.IncludeObsoletes(); ``` -snippet source | anchor +snippet source | anchor Result: diff --git a/docs/scrubbers.md b/docs/scrubbers.md index beab920dd0..9ea9c2573a 100644 --- a/docs/scrubbers.md +++ b/docs/scrubbers.md @@ -59,7 +59,7 @@ For example remove lines containing `text`: ```cs verifySettings.ScrubLines(line => line.Contains("text")); ``` -snippet source | anchor +snippet source | anchor @@ -74,7 +74,7 @@ For example remove lines containing `text1` or `text2` ```cs verifySettings.ScrubLinesContaining("text1", "text2"); ``` -snippet source | anchor +snippet source | anchor Case insensitive by default (`StringComparison.OrdinalIgnoreCase`). @@ -86,7 +86,7 @@ Case insensitive by default (`StringComparison.OrdinalIgnoreCase`). ```cs verifySettings.ScrubLinesContaining(StringComparison.Ordinal, "text1", "text2"); ``` -snippet source | anchor +snippet source | anchor @@ -101,7 +101,7 @@ For example converts lines to upper case: ```cs verifySettings.ScrubLinesWithReplace(line => line.ToUpper()); ``` -snippet source | anchor +snippet source | anchor @@ -114,7 +114,7 @@ Replaces `Environment.MachineName` with `TheMachineName`. ```cs verifySettings.ScrubMachineName(); ``` -snippet source | anchor +snippet source | anchor @@ -127,7 +127,7 @@ Replaces `Environment.UserName` with `TheUserName`. ```cs verifySettings.ScrubUserName(); ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/serializer-settings.md b/docs/serializer-settings.md index 6346c6c0d1..ecf1e3a376 100644 --- a/docs/serializer-settings.md +++ b/docs/serializer-settings.md @@ -132,7 +132,7 @@ var settings = new JsonSerializerSettings DefaultValueHandling = DefaultValueHandling.Ignore }; ``` -snippet source | anchor +snippet source | anchor @@ -204,7 +204,7 @@ To disable this behavior globally use: ```cs VerifierSettings.DontIgnoreEmptyCollections(); ``` -snippet source | anchor +snippet source | anchor @@ -495,7 +495,7 @@ public Task ScopedSerializerFluent() .AddExtraSettings(_ => _.TypeNameHandling = TypeNameHandling.All); } ``` -snippet source | anchor +snippet source | anchor Result: @@ -623,7 +623,7 @@ public Task IgnoreTypeFluent() .IgnoreMembersWithType(); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -633,7 +633,7 @@ Or globally: ```cs VerifierSettings.IgnoreMembersWithType(); ``` -snippet source | anchor +snippet source | anchor Result: @@ -770,7 +770,7 @@ public Task ScrubTypeFluent() .ScrubMembersWithType(); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -780,7 +780,7 @@ Or globally: ```cs VerifierSettings.ScrubMembersWithType(); ``` -snippet source | anchor +snippet source | anchor Result: @@ -859,7 +859,7 @@ public Task AddIgnoreInstanceFluent() .IgnoreInstance(_ => _.Property == "Ignore"); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -869,7 +869,7 @@ Or globally: ```cs VerifierSettings.IgnoreInstance(_ => _.Property == "Ignore"); ``` -snippet source | anchor +snippet source | anchor Result: @@ -931,7 +931,7 @@ public Task AddScrubInstanceFluent() .ScrubInstance(_ => _.Property == "Ignore"); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -941,7 +941,7 @@ Or globally: ```cs VerifierSettings.ScrubInstance(_ => _.Property == "Ignore"); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1004,7 +1004,7 @@ public Task IgnoreMemberByExpressionFluent() _ => _.PropertyThatThrows); } ``` -snippet source | anchor +snippet source | anchor Or globally @@ -1019,7 +1019,7 @@ VerifierSettings.IgnoreMembers( _ => _.GetOnlyProperty, _ => _.PropertyThatThrows); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1079,7 +1079,7 @@ public Task ScrubMemberByExpressionFluent() _ => _.PropertyThatThrows); } ``` -snippet source | anchor +snippet source | anchor Or globally @@ -1094,7 +1094,7 @@ VerifierSettings.ScrubMembers( _ => _.GetOnlyProperty, _ => _.PropertyThatThrows); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1173,7 +1173,7 @@ public Task IgnoreMemberByNameFluent() .IgnoreMember(_ => _.PropertyThatThrows); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -1193,7 +1193,7 @@ VerifierSettings.IgnoreMember("Field"); // For a specific type with expression VerifierSettings.IgnoreMember(_ => _.PropertyThatThrows); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1268,7 +1268,7 @@ public Task ScrubMemberByNameFluent() .ScrubMember(_ => _.PropertyThatThrows); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -1288,7 +1288,7 @@ VerifierSettings.ScrubMember("Field"); // For a specific type with expression VerifierSettings.ScrubMember(_ => _.PropertyThatThrows); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1381,7 +1381,7 @@ public Task IgnoreDictionaryByPredicate() return Verify(target, settings); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -1393,7 +1393,7 @@ VerifierSettings.IgnoreMembers( _=>_.DeclaringType == typeof(TargetClass) && _.Name == "Proprty"); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1482,7 +1482,7 @@ public Task ScrubDictionaryByPredicate() return Verify(target, settings); } ``` -snippet source | anchor +snippet source | anchor Or globally: @@ -1494,7 +1494,7 @@ VerifierSettings.ScrubMembers( _=>_.DeclaringType == typeof(TargetClass) && _.Name == "Proprty"); ``` -snippet source | anchor +snippet source | anchor Result: @@ -1548,7 +1548,7 @@ public Task MemberConverterByExpression() return Verify(input); } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/StrictJsonTests/SerializationTests.ScrubNumericIdsFluent.verified.json b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsFluent.verified.json new file mode 100644 index 0000000000..8477d5f819 --- /dev/null +++ b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsFluent.verified.json @@ -0,0 +1,7 @@ +{ + "Id": "Id_1", + "UserId": "UserId_1", + "userID": "userID_1", + "Name": "Test", + "Count": 10 +} \ No newline at end of file diff --git a/src/StrictJsonTests/SerializationTests.ScrubNumericIdsInstance.verified.json b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsInstance.verified.json new file mode 100644 index 0000000000..a53f5be4a5 --- /dev/null +++ b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsInstance.verified.json @@ -0,0 +1,5 @@ +{ + "Id": "Id_1", + "UserId": "UserId_1", + "Name": "Test" +} \ No newline at end of file diff --git a/src/StrictJsonTests/SerializationTests.ScrubNumericIdsLong.verified.json b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsLong.verified.json new file mode 100644 index 0000000000..c0ce827928 --- /dev/null +++ b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsLong.verified.json @@ -0,0 +1,4 @@ +{ + "Id": "Id_1", + "Name": "Test" +} \ No newline at end of file diff --git a/src/StrictJsonTests/SerializationTests.ScrubNumericIdsNamedType.verified.json b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsNamedType.verified.json new file mode 100644 index 0000000000..79fe14a86f --- /dev/null +++ b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsNamedType.verified.json @@ -0,0 +1,44 @@ +[ + { + "Id": "Customer_1", + "Name": "Alice", + "Orders": [ + { + "Id": "Order_1", + "CustomerId": "CustomerId_1", + "Items": [ + { + "Id": "OrderItem_1", + "OrderId": "OrderId_1", + "ProductId": "ProductId_1", + "Quantity": 2 + }, + { + "Id": "OrderItem_2", + "OrderId": "OrderId_1", + "ProductId": "ProductId_2", + "Quantity": 1 + } + ] + } + ] + }, + { + "Id": "Customer_2", + "Name": "Bob", + "Orders": [ + { + "Id": "Order_2", + "CustomerId": "CustomerId_2", + "Items": [ + { + "Id": "OrderItem_3", + "OrderId": "OrderId_2", + "ProductId": "ProductId_1", + "Quantity": 5 + } + ] + } + ] + } +] \ No newline at end of file diff --git a/src/StrictJsonTests/SerializationTests.ScrubNumericIdsSameValues.verified.json b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsSameValues.verified.json new file mode 100644 index 0000000000..9f549a89db --- /dev/null +++ b/src/StrictJsonTests/SerializationTests.ScrubNumericIdsSameValues.verified.json @@ -0,0 +1,5 @@ +{ + "OrderId": "OrderId_1", + "ParentId": "ParentId_1", + "OtherId": "OtherId_1" +} \ No newline at end of file diff --git a/src/Verify.Tests/Naming/CounterBuilder.cs b/src/Verify.Tests/Naming/CounterBuilder.cs index c535824bb6..9b84ad7555 100644 --- a/src/Verify.Tests/Naming/CounterBuilder.cs +++ b/src/Verify.Tests/Naming/CounterBuilder.cs @@ -5,6 +5,7 @@ public static Counter Empty() => true, true, true, + false, #if NET6_0_OR_GREATER [], [], diff --git a/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsFluent.verified.txt b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsFluent.verified.txt new file mode 100644 index 0000000000..321fdfb12b --- /dev/null +++ b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsFluent.verified.txt @@ -0,0 +1,7 @@ +{ + Id: Id_1, + UserId: UserId_1, + userID: userID_1, + Name: Test, + Count: 10 +} \ No newline at end of file diff --git a/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsInstance.verified.txt b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsInstance.verified.txt new file mode 100644 index 0000000000..ca66501037 --- /dev/null +++ b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsInstance.verified.txt @@ -0,0 +1,5 @@ +{ + Id: Id_1, + UserId: UserId_1, + Name: Test +} \ No newline at end of file diff --git a/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsLong.verified.txt b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsLong.verified.txt new file mode 100644 index 0000000000..66f0e183c5 --- /dev/null +++ b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsLong.verified.txt @@ -0,0 +1,4 @@ +{ + Id: Id_1, + Name: Test +} \ No newline at end of file diff --git a/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsNamedType.verified.txt b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsNamedType.verified.txt new file mode 100644 index 0000000000..52a86651dd --- /dev/null +++ b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsNamedType.verified.txt @@ -0,0 +1,44 @@ +[ + { + Id: Customer_1, + Name: Alice, + Orders: [ + { + Id: Order_1, + CustomerId: CustomerId_1, + Items: [ + { + Id: OrderItem_1, + OrderId: OrderId_1, + ProductId: ProductId_1, + Quantity: 2 + }, + { + Id: OrderItem_2, + OrderId: OrderId_1, + ProductId: ProductId_2, + Quantity: 1 + } + ] + } + ] + }, + { + Id: Customer_2, + Name: Bob, + Orders: [ + { + Id: Order_2, + CustomerId: CustomerId_2, + Items: [ + { + Id: OrderItem_3, + OrderId: OrderId_2, + ProductId: ProductId_1, + Quantity: 5 + } + ] + } + ] + } +] \ No newline at end of file diff --git a/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsSameValues.verified.txt b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsSameValues.verified.txt new file mode 100644 index 0000000000..7386722fc0 --- /dev/null +++ b/src/Verify.Tests/Serialization/SerializationTests.ScrubNumericIdsSameValues.verified.txt @@ -0,0 +1,5 @@ +{ + OrderId: OrderId_1, + ParentId: ParentId_1, + OtherId: OtherId_1 +} \ No newline at end of file diff --git a/src/Verify.Tests/Serialization/SerializationTests.cs b/src/Verify.Tests/Serialization/SerializationTests.cs index 3a6bfc81db..c8c5439adb 100644 --- a/src/Verify.Tests/Serialization/SerializationTests.cs +++ b/src/Verify.Tests/Serialization/SerializationTests.cs @@ -732,6 +732,166 @@ public Task GuidScrubbingDisabledNested() => }) .DontScrubGuids(); + [Fact] + public Task ScrubNumericIdsFluent() + { + #region ScrubNumericIdsFluent + + var target = new + { + Id = 123, + UserId = 456, + userID = 789, + Name = "Test", + Count = 10 + }; + return Verify(target) + .ScrubNumericIds(); + + #endregion + } + + [Fact] + public Task ScrubNumericIdsInstance() + { + #region ScrubNumericIdsInstance + + var target = new + { + Id = 123, + UserId = 456, + Name = "Test" + }; + var settings = new VerifySettings(); + settings.ScrubNumericIds(); + return Verify(target, settings); + + #endregion + } + + // ReSharper disable once UnusedMember.Local + static void ScrubNumericIdsGlobal() => + + #region ScrubNumericIdsGlobal + + VerifierSettings.ScrubNumericIds(); + + #endregion + + [Fact] + public Task ScrubNumericIdsSameValues() + { + var target = new + { + OrderId = 42, + ParentId = 42, + OtherId = 99 + }; + return Verify(target) + .ScrubNumericIds(); + } + + [Fact] + public Task ScrubNumericIdsLong() + { + var target = new + { + Id = 999999999999L, + Name = "Test" + }; + return Verify(target) + .ScrubNumericIds(); + } + + #region ScrubNumericIdsRelationships + + public class Customer + { + public int Id; + public string? Name; + public List Orders = []; + } + + public class Order + { + public int Id; + public int CustomerId; + public List Items = []; + } + + public class OrderItem + { + public long Id; + public int OrderId; + public int ProductId; + public int Quantity; + } + + [Fact] + public Task ScrubNumericIdsNamedType() + { + var target = new List + { + new() + { + Id = 1023, + Name = "Alice", + Orders = + [ + new() + { + Id = 5001, + CustomerId = 1023, + Items = + [ + new() + { + Id = 90_001, + OrderId = 5001, + ProductId = 7, + Quantity = 2 + }, + new() + { + Id = 90_002, + OrderId = 5001, + ProductId = 12, + Quantity = 1 + } + ] + } + ] + }, + new() + { + Id = 1099, + Name = "Bob", + Orders = + [ + new() + { + Id = 5002, + CustomerId = 1099, + Items = + [ + new() + { + Id = 90_003, + OrderId = 5002, + ProductId = 7, + Quantity = 5 + } + ] + } + ] + } + }; + return Verify(target) + .ScrubNumericIds(); + } + + #endregion + [Fact] public Task ScrubberWithBadNewLine() => Verify("a") diff --git a/src/Verify.Tests/Snippets/NumericIdSample.cs b/src/Verify.Tests/Snippets/NumericIdSample.cs index 77532664d9..98ba5e76ff 100644 --- a/src/Verify.Tests/Snippets/NumericIdSample.cs +++ b/src/Verify.Tests/Snippets/NumericIdSample.cs @@ -19,7 +19,7 @@ public Task Test() { var target = new Target { - Id = new Random().Next(), + Id = Random.Shared.Next(), Name = "The Name" }; return Verify(target); @@ -31,4 +31,4 @@ public interface IHasId } } -#endregion \ No newline at end of file +#endregion diff --git a/src/Verify.Tests/Wizard/WizardGen.cs b/src/Verify.Tests/Wizard/WizardGen.cs index 1a4294b116..16b1ce8fd6 100644 --- a/src/Verify.Tests/Wizard/WizardGen.cs +++ b/src/Verify.Tests/Wizard/WizardGen.cs @@ -35,7 +35,7 @@ public async Task Run() File.Delete(sourceFile); await WriteLf(sourceFile, builder); var process = Process.Start("mdsnippets", repoRoot); - await process!.WaitForExitAsync(); + await process.WaitForExitAsync(); } static Task WriteLf(string path, StringBuilder builder) => diff --git a/src/Verify/Counter.cs b/src/Verify/Counter.cs index 956acdbf7a..1a2c2ce34e 100644 --- a/src/Verify/Counter.cs +++ b/src/Verify/Counter.cs @@ -13,6 +13,7 @@ public partial class Counter : public bool DateCounting { get; } public bool ScrubDateTimes { get; } public bool ScrubGuids { get; } + public bool ScrubNumericIds { get; } static AsyncLocal local = new(); internal bool TryGetNamed(object value, [NotNullWhen(true)] out string? result) @@ -103,6 +104,7 @@ public Counter( bool dateCounting, bool scrubDateTimes, bool scrubGuids, + bool scrubNumericIds, #if NET6_0_OR_GREATER Dictionary namedDates, Dictionary namedTimes, @@ -121,12 +123,14 @@ public Counter( DateCounting = dateCounting; ScrubDateTimes = scrubDateTimes; ScrubGuids = scrubGuids; + ScrubNumericIds = scrubNumericIds; } internal static Counter Start( bool dateCounting = true, bool scrubDateTimes = true, bool scrubGuids = true, + bool scrubNumericIds = false, #if NET6_0_OR_GREATER Dictionary? namedDates = null, Dictionary? namedTimes = null, @@ -139,6 +143,7 @@ internal static Counter Start( dateCounting, scrubDateTimes, scrubGuids, + scrubNumericIds, #if NET6_0_OR_GREATER namedDates ?? [], namedTimes ?? [], diff --git a/src/Verify/Counter_NumericId.cs b/src/Verify/Counter_NumericId.cs new file mode 100644 index 0000000000..9ce59109c3 --- /dev/null +++ b/src/Verify/Counter_NumericId.cs @@ -0,0 +1,36 @@ +namespace VerifyTests; + +public partial class Counter +{ + Dictionary> numericIdCache = []; + Dictionary numericIdCounters = []; + + public int NextNumericId(string entityName, long input) => + NextNumericIdValue(entityName, input) + .intValue; + + public string NextNumericIdString(string entityName, long input) => + NextNumericIdValue(entityName, input) + .stringValue; + + (int intValue, string stringValue) NextNumericIdValue(string entityName, long input) + { + if (!numericIdCache.TryGetValue(entityName, out var cache)) + { + cache = []; + numericIdCache[entityName] = cache; + } + + return cache.GetOrAdd( + input, + _ => BuildNumericIdValue(entityName)); + } + + (int intValue, string stringValue) BuildNumericIdValue(string entityName) + { + numericIdCounters.TryGetValue(entityName, out var current); + current++; + numericIdCounters[entityName] = current; + return (current, $"{entityName}_{current}"); + } +} diff --git a/src/Verify/Serialization/CustomContractResolver.cs b/src/Verify/Serialization/CustomContractResolver.cs index 8cfdd19e13..7d2a13d036 100644 --- a/src/Verify/Serialization/CustomContractResolver.cs +++ b/src/Verify/Serialization/CustomContractResolver.cs @@ -13,6 +13,22 @@ static ItemInterceptResult ToInterceptItemResult(ScrubOrIgnore scrubOrIgnore) static FieldInfo exceptionMessageField = typeof(Exception).GetField("_message", BindingFlags.Instance | BindingFlags.NonPublic)!; + static string DeriveEntityName(MemberInfo member) + { + if (member.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)) + { + var declaringType = member.DeclaringType!; + if (declaringType.Name.Contains("AnonymousType")) + { + return member.Name; + } + + return declaringType.Name; + } + + return member.Name; + } + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) { var properties = base.CreateProperties(type, memberSerialization); @@ -94,6 +110,18 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ property.DefaultValueHandling = DefaultValueHandling.Include; } + if (settings.ScrubNumericIds == true && + memberType.IsNumeric() && + (member.Name.EndsWith("Id", StringComparison.Ordinal) || + member.Name.EndsWith("ID", StringComparison.Ordinal))) + { + var entityName = DeriveEntityName(member); + + property.PropertyType = typeof(string); + property.ValueProvider = new NumericIdScrubProvider(valueProvider, entityName); + return property; + } + property.ValueProvider = new CustomValueProvider( valueProvider, memberType, diff --git a/src/Verify/Serialization/NumericIdScrubProvider.cs b/src/Verify/Serialization/NumericIdScrubProvider.cs new file mode 100644 index 0000000000..acf2e59ee9 --- /dev/null +++ b/src/Verify/Serialization/NumericIdScrubProvider.cs @@ -0,0 +1,18 @@ +class NumericIdScrubProvider(IValueProvider inner, string entityName) : + IValueProvider +{ + public void SetValue(object target, object? value) => + throw new NotImplementedException(); + + public object GetValue(object target) + { + var value = inner.GetValue(target); + if (value is null) + { + return $"{entityName}_null"; + } + + var longValue = Convert.ToInt64(value); + return Counter.Current.NextNumericIdString(entityName, longValue); + } +} diff --git a/src/Verify/Serialization/SerializationSettings.cs b/src/Verify/Serialization/SerializationSettings.cs index 1d40a5168d..8acd0abebc 100644 --- a/src/Verify/Serialization/SerializationSettings.cs +++ b/src/Verify/Serialization/SerializationSettings.cs @@ -69,6 +69,7 @@ public SerializationSettings(SerializationSettings settings) } ScrubGuids = settings.ScrubGuids; + ScrubNumericIds = settings.ScrubNumericIds; includeObsoletes = settings.includeObsoletes; ignoredMemberPredicatesByString = settings.ignoredMemberPredicatesByString?.Clone(); ignoredMemberPredicatesByMember = settings.ignoredMemberPredicatesByMember?.Clone(); @@ -88,6 +89,8 @@ public void DontScrubGuids() => public void DontScrubDateTimes() => ScrubDateTimes = false; + public bool? ScrubNumericIds { get; set; } + JsonSerializerSettings BuildSettings() { #region defaultSerialization diff --git a/src/Verify/Serialization/VerifierSettings_SerializationMaps.cs b/src/Verify/Serialization/VerifierSettings_SerializationMaps.cs index d17eed338c..3d3f02beeb 100644 --- a/src/Verify/Serialization/VerifierSettings_SerializationMaps.cs +++ b/src/Verify/Serialization/VerifierSettings_SerializationMaps.cs @@ -46,6 +46,12 @@ public static void DontScrubDateTimes() serialization.ScrubDateTimes = false; } + public static void ScrubNumericIds() + { + InnerVerifier.ThrowIfVerifyHasBeenRun(); + serialization.ScrubNumericIds = true; + } + public static void DontSortDictionaries() { InnerVerifier.ThrowIfVerifyHasBeenRun(); diff --git a/src/Verify/Serialization/VerifySettings_SerializationMaps.cs b/src/Verify/Serialization/VerifySettings_SerializationMaps.cs index 5b64e7ccdd..ccd9c12f6d 100644 --- a/src/Verify/Serialization/VerifySettings_SerializationMaps.cs +++ b/src/Verify/Serialization/VerifySettings_SerializationMaps.cs @@ -26,6 +26,18 @@ public void ScrubDateTimes() serialization.ScrubDateTimes = true; } + public void ScrubNumericIds() + { + CloneSettings(); + serialization.ScrubNumericIds = true; + } + + public void DontScrubNumericIds() + { + CloneSettings(); + serialization.ScrubNumericIds = false; + } + public void DontSortDictionaries() { CloneSettings(); diff --git a/src/Verify/SettingsTask_Scrubbing.cs b/src/Verify/SettingsTask_Scrubbing.cs index d22656437f..947666d540 100644 --- a/src/Verify/SettingsTask_Scrubbing.cs +++ b/src/Verify/SettingsTask_Scrubbing.cs @@ -42,6 +42,14 @@ public SettingsTask ScrubGuids() return this; } + /// + [Pure] + public SettingsTask ScrubNumericIds() + { + CurrentSettings.ScrubNumericIds(); + return this; + } + /// [Pure] public SettingsTask ScrubInlineGuids(string extension, ScrubberLocation location = ScrubberLocation.First) diff --git a/src/Verify/SettingsTask_SerializationMaps.cs b/src/Verify/SettingsTask_SerializationMaps.cs index 6f9e6973c8..249c9a9dd5 100644 --- a/src/Verify/SettingsTask_SerializationMaps.cs +++ b/src/Verify/SettingsTask_SerializationMaps.cs @@ -26,6 +26,14 @@ public SettingsTask DontScrubDateTimes() return this; } + /// + [Pure] + public SettingsTask DontScrubNumericIds() + { + CurrentSettings.DontScrubNumericIds(); + return this; + } + /// [Pure] public SettingsTask DontSortDictionaries() diff --git a/src/Verify/Verifier/InnerVerifier.cs b/src/Verify/Verifier/InnerVerifier.cs index ed4957a9ec..770b87da1d 100644 --- a/src/Verify/Verifier/InnerVerifier.cs +++ b/src/Verify/Verifier/InnerVerifier.cs @@ -173,6 +173,7 @@ static Counter StartCounter(VerifySettings settings) => settings is {DateCountingEnable: true, ScrubbersEnabled: true}, settings.serialization.ScrubDateTimes.GetValueOrDefault(true) && settings.ScrubbersEnabled, settings.serialization.ScrubGuids.GetValueOrDefault(true) && settings.ScrubbersEnabled, + settings.serialization.ScrubNumericIds.GetValueOrDefault(false) && settings.ScrubbersEnabled, #if NET6_0_OR_GREATER settings.namedDates, settings.namedTimes,