Skip to content
25 changes: 24 additions & 1 deletion src/EFCore/ChangeTracking/ValueComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,14 @@ public static ValueComparer CreateDefault
return new DefaultDateTimeOffsetValueComparer(favorStructuralComparisons);
}

if (nonNullableType == typeof(string))
{
return new DefaultStringValueComparer(favorStructuralComparisons);
}
Comment on lines +307 to +310

return nonNullableType.IsInteger()
|| nonNullableType == typeof(decimal)
|| nonNullableType == typeof(bool)
|| nonNullableType == typeof(string)
|| nonNullableType == typeof(DateTime)
|| nonNullableType == typeof(DateOnly)
|| nonNullableType == typeof(Guid)
Expand Down Expand Up @@ -376,4 +380,23 @@ private static readonly MethodInfo EqualsExactMethodInfo
public override Expression ExtractEqualsBody(Expression leftExpression, Expression rightExpression)
=> Expression.Call(leftExpression, EqualsExactMethodInfo, rightExpression);
}

#pragma warning disable CA1309 // Use ordinal StringComparison - InvariantCulture is intentional to handle Unicode canonical equivalence (NFC/NFD)
internal sealed class DefaultStringValueComparer(bool favorStructuralComparisons)
: DefaultValueComparer<string>((v1, v2) => string.Equals(v1, v2, StringComparison.InvariantCulture), favorStructuralComparisons)
{
private static readonly MethodInfo StringEqualsMethodInfo
= typeof(string).GetMethod(nameof(string.Equals), [typeof(string), typeof(string), typeof(StringComparison)])!;

// String canonical equivalence (e.g. NFC vs NFD Unicode normalization) is handled by InvariantCulture comparison.
// Override hash code to be consistent with InvariantCulture equality so that canonically equivalent strings
// produce the same hash code.
public override int GetHashCode(string instance)
=> StringComparer.InvariantCulture.GetHashCode(instance);

public override Expression ExtractEqualsBody(Expression leftExpression, Expression rightExpression)
=> Expression.Call(StringEqualsMethodInfo, leftExpression, rightExpression, Expression.Constant(StringComparison.InvariantCulture));
}
Comment on lines +384 to +399
#pragma warning restore CA1309
}

Original file line number Diff line number Diff line change
Expand Up @@ -8369,6 +8369,35 @@ public void Change_TPT_to_TPC_with_FKs_and_seed_data()
Assert.Equal(ReferentialAction.Cascade, operation.OnDelete);
}));

[ConditionalFact]
public void Seed_data_with_NFD_vs_NFC_unicode_string_is_considered_unchanged()
{
var nfcString = "Caf\u00E9";
var nfdString = "Cafe\u0301";

Assert.NotEqual(nfcString, nfdString);
Assert.Equal(nfcString, nfdString.Normalize());

Execute(
source => source.Entity(
"BusinessType",
x =>
{
x.Property<int>("BusinessTypeId");
x.Property<string>("WebDescription");
x.HasData(new { BusinessTypeId = 28, WebDescription = nfcString });
}),
target => target.Entity(
"BusinessType",
x =>
{
x.Property<int>("BusinessTypeId");
x.Property<string>("WebDescription");
x.HasData(new { BusinessTypeId = 28, WebDescription = nfdString });
}),
operations => Assert.Empty(operations));
}
Comment thread
TomasMoralesBarr marked this conversation as resolved.

[ConditionalFact]
public void Change_TPT_to_TPC_with_excluded_base()
=> Execute(
Expand Down
26 changes: 26 additions & 0 deletions test/EFCore.Tests/ChangeTracking/ValueComparerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,33 @@ public void Can_create_new_comparer_composing_existing_comparers()
Assert.NotEqual(getHashCode(value2), getHashCode(value1a));
Assert.NotEqual(getKeyHashCode(value2), getKeyHashCode(value1a));
}
[ConditionalFact]
public void Default_string_comparer_treats_NFC_and_NFD_as_equal()
{
var nfcString = "Caf\u00E9"; // é as precomposed NFC character
var nfdString = "Cafe\u0301"; // e + combining acute accent (NFD)

Assert.NotEqual(nfcString, nfdString); // ordinal: not equal

var comparer = ValueComparer.CreateDefault<string>(false);

Assert.True(comparer.Equals(nfcString, nfdString));
Assert.Equal(comparer.GetHashCode(nfcString), comparer.GetHashCode(nfdString));
}

[ConditionalFact]
public void Default_string_comparer_extract_equals_body_uses_invariant_culture()
{
var comparer = ValueComparer.CreateDefault<string>(false);

var p1 = Expression.Parameter(typeof(string), "s1");
var p2 = Expression.Parameter(typeof(string), "s2");
var body = comparer.ExtractEqualsBody(p1, p2);
var lambda = Expression.Lambda<Func<string, string, bool>>(body, p1, p2).Compile();

Assert.True(lambda("Caf\u00E9", "Cafe\u0301"));
Assert.False(lambda("abc", "xyz"));
}
private static LambdaExpression CreateAndExpression(ValueComparer comparer)
{
var param1 = Expression.Parameter(typeof(DeepBinary), "v1");
Expand Down
Loading