Skip to content

Commit fd3808a

Browse files
AndyButlandCopilot
andcommitted
Ensure tag operations are case insensitive on insert across database types (#19439)
* Ensure tag operations are case insensitve on insert across database types. * Ensure tags provided in a single property are case insensitively distinct when saving the tags and relationships. * Update src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs Co-authored-by: Copilot <[email protected]> * Handle case sensitivity on insert with tag groups too. --------- Co-authored-by: Copilot <[email protected]>
1 parent c4be784 commit fd3808a

File tree

2 files changed

+104
-5
lines changed

2 files changed

+104
-5
lines changed

src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,13 @@ public void Assign(int contentId, int propertyTypeId, IEnumerable<ITag> tags, bo
128128
var group = SqlSyntax.GetQuotedColumnName("group");
129129

130130
// insert tags
131+
// - Note we are checking in the subquery for the existence of the tag, so we don't insert duplicates, using a case-insensitive comparison (the
132+
// LOWER keyword is consistent across SQLite and SQLServer). This ensures consistent behavior across databases as by default, SQLServer will
133+
// perform a case-insensitive comparison, while SQLite will not.
131134
var sql1 = $@"INSERT INTO cmsTags (tag, {group}, languageId)
132135
SELECT tagSet.tag, tagSet.{group}, tagSet.languageId
133136
FROM {tagSetSql}
134-
LEFT OUTER JOIN cmsTags ON (tagSet.tag = cmsTags.tag AND tagSet.{group} = cmsTags.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
137+
LEFT OUTER JOIN cmsTags ON (LOWER(tagSet.tag) = LOWER(cmsTags.tag) AND LOWER(tagSet.{group}) = LOWER(cmsTags.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
135138
WHERE cmsTags.id IS NULL";
136139

137140
Database.Execute(sql1);
@@ -142,7 +145,7 @@ LEFT OUTER JOIN cmsTags ON (tagSet.tag = cmsTags.tag AND tagSet.{group} = cmsTag
142145
FROM (
143146
SELECT t.Id
144147
FROM {tagSetSql}
145-
INNER JOIN cmsTags as t ON (tagSet.tag = t.tag AND tagSet.{group} = t.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
148+
INNER JOIN cmsTags as t ON (LOWER(tagSet.tag) = LOWER(t.tag) AND LOWER(tagSet.{group}) = LOWER(t.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
146149
) AS tagSet2
147150
LEFT OUTER JOIN cmsTagRelationship r ON (tagSet2.id = r.tagId AND r.nodeId = {contentId} AND r.propertyTypeID = {propertyTypeId})
148151
WHERE r.tagId IS NULL";
@@ -246,14 +249,18 @@ private class TagComparer : IEqualityComparer<ITag>
246249
{
247250
public bool Equals(ITag? x, ITag? y) =>
248251
ReferenceEquals(x, y) // takes care of both being null
249-
|| (x != null && y != null && x.Text == y.Text && x.Group == y.Group && x.LanguageId == y.LanguageId);
252+
|| (x != null &&
253+
y != null &&
254+
string.Equals(x.Text, y.Text, StringComparison.OrdinalIgnoreCase) &&
255+
string.Equals(x.Group, y.Group, StringComparison.OrdinalIgnoreCase) &&
256+
x.LanguageId == y.LanguageId);
250257

251258
public int GetHashCode(ITag obj)
252259
{
253260
unchecked
254261
{
255-
var h = obj.Text.GetHashCode();
256-
h = (h * 397) ^ obj.Group.GetHashCode();
262+
var h = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Text);
263+
h = (h * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Group);
257264
h = (h * 397) ^ (obj.LanguageId?.GetHashCode() ?? 0);
258265
return h;
259266
}

tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,98 @@ public void Can_Get_Tagged_Entities_For_Tag()
10471047
}
10481048
}
10491049

1050+
[Test]
1051+
public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Tag()
1052+
{
1053+
var provider = ScopeProvider;
1054+
using (var scope = ScopeProvider.CreateScope())
1055+
{
1056+
(IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests();
1057+
1058+
var repository = CreateRepository(provider);
1059+
1060+
// Note two tags are applied, but they differ only in case for the tag.
1061+
Tag[] tags1 = { new() { Text = "tag1", Group = "test" }, new() { Text = "Tag1", Group = "test" } };
1062+
repository.Assign(
1063+
content1.Id,
1064+
contentType.PropertyTypes.First().Id,
1065+
tags1,
1066+
false);
1067+
1068+
// Note the casing is different from the tag in tags1, but both should be considered equivalent.
1069+
Tag[] tags2 = { new() { Text = "TAG1", Group = "test" } };
1070+
repository.Assign(
1071+
content2.Id,
1072+
contentType.PropertyTypes.First().Id,
1073+
tags2,
1074+
false);
1075+
1076+
// Only one tag should have been saved.
1077+
var tagCount = scope.Database.ExecuteScalar<int>(
1078+
"SELECT COUNT(*) FROM cmsTags WHERE [group] = 'test'");
1079+
Assert.AreEqual(1, tagCount);
1080+
1081+
// Both content items should be found as tagged by the tag, even though one was assigned with the tag differing in case.
1082+
Assert.AreEqual(2, repository.GetTaggedEntitiesByTag(TaggableObjectTypes.Content, "tag1").Count());
1083+
}
1084+
}
1085+
1086+
[Test]
1087+
public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Group()
1088+
{
1089+
var provider = ScopeProvider;
1090+
using (var scope = ScopeProvider.CreateScope())
1091+
{
1092+
(IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests();
1093+
1094+
var repository = CreateRepository(provider);
1095+
1096+
// Note two tags are applied, but they differ only in case for the group.
1097+
Tag[] tags1 = { new() { Text = "tag1", Group = "group1" }, new() { Text = "tag1", Group = "Group1" } };
1098+
repository.Assign(
1099+
content1.Id,
1100+
contentType.PropertyTypes.First().Id,
1101+
tags1,
1102+
false);
1103+
1104+
// Note the casing is different from the group in tags1, but both should be considered equivalent.
1105+
Tag[] tags2 = { new() { Text = "tag1", Group = "GROUP1" } };
1106+
repository.Assign(
1107+
content2.Id,
1108+
contentType.PropertyTypes.First().Id,
1109+
tags2,
1110+
false);
1111+
1112+
// Only one tag/group should have been saved.
1113+
var tagCount = scope.Database.ExecuteScalar<int>(
1114+
"SELECT COUNT(*) FROM cmsTags WHERE [tag] = 'tag1'");
1115+
Assert.AreEqual(1, tagCount);
1116+
1117+
var groupCount = scope.Database.ExecuteScalar<int>(
1118+
"SELECT COUNT(*) FROM cmsTags WHERE [group] = 'group1'");
1119+
Assert.AreEqual(1, groupCount);
1120+
1121+
// Both content items should be found as tagged by the tag, even though one was assigned with the group differing in case.
1122+
Assert.AreEqual(2, repository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Content, "group1").Count());
1123+
}
1124+
}
1125+
1126+
private (IContentType ContentType, IContent Content1, IContent Content2) CreateContentForCreateTagTests()
1127+
{
1128+
var template = TemplateBuilder.CreateTextPageTemplate();
1129+
FileService.SaveTemplate(template);
1130+
1131+
var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id);
1132+
ContentTypeRepository.Save(contentType);
1133+
1134+
var content1 = ContentBuilder.CreateSimpleContent(contentType);
1135+
var content2 = ContentBuilder.CreateSimpleContent(contentType);
1136+
DocumentRepository.Save(content1);
1137+
DocumentRepository.Save(content2);
1138+
1139+
return (contentType, content1, content2);
1140+
}
1141+
10501142
private TagRepository CreateRepository(IScopeProvider provider) =>
10511143
new((IScopeAccessor)provider, AppCaches.Disabled, LoggerFactory.CreateLogger<TagRepository>());
10521144
}

0 commit comments

Comments
 (0)