diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs index af28b8325c..d0d688d332 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs @@ -128,10 +128,13 @@ internal class TagRepository : EntityRepositoryBase, ITagRepository var group = SqlSyntax.GetQuotedColumnName("group"); // insert tags + // - 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 + // LOWER keyword is consistent across SQLite and SQLServer). This ensures consistent behavior across databases as by default, SQLServer will + // perform a case-insensitive comparison, while SQLite will not. var sql1 = $@"INSERT INTO cmsTags (tag, {group}, languageId) SELECT tagSet.tag, tagSet.{group}, tagSet.languageId FROM {tagSetSql} -LEFT OUTER JOIN cmsTags ON (tagSet.tag = cmsTags.tag AND tagSet.{group} = cmsTags.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1)) +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)) WHERE cmsTags.id IS NULL"; Database.Execute(sql1); @@ -142,7 +145,7 @@ SELECT {contentId}, {propertyTypeId}, tagSet2.Id FROM ( SELECT t.Id FROM {tagSetSql} - INNER JOIN cmsTags as t ON (tagSet.tag = t.tag AND tagSet.{group} = t.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1)) + 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)) ) AS tagSet2 LEFT OUTER JOIN cmsTagRelationship r ON (tagSet2.id = r.tagId AND r.nodeId = {contentId} AND r.propertyTypeID = {propertyTypeId}) WHERE r.tagId IS NULL"; @@ -246,14 +249,18 @@ WHERE r.tagId IS NULL"; { public bool Equals(ITag? x, ITag? y) => ReferenceEquals(x, y) // takes care of both being null - || (x != null && y != null && x.Text == y.Text && x.Group == y.Group && x.LanguageId == y.LanguageId); + || (x != null && + y != null && + string.Equals(x.Text, y.Text, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Group, y.Group, StringComparison.OrdinalIgnoreCase) && + x.LanguageId == y.LanguageId); public int GetHashCode(ITag obj) { unchecked { - var h = obj.Text.GetHashCode(); - h = (h * 397) ^ obj.Group.GetHashCode(); + var h = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Text); + h = (h * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Group); h = (h * 397) ^ (obj.LanguageId?.GetHashCode() ?? 0); return h; } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs index 0d2dd06524..9708504490 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs @@ -1047,6 +1047,98 @@ internal sealed class TagRepositoryTest : UmbracoIntegrationTest } } + [Test] + public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Tag() + { + var provider = ScopeProvider; + using (var scope = ScopeProvider.CreateScope()) + { + (IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests(); + + var repository = CreateRepository(provider); + + // Note two tags are applied, but they differ only in case for the tag. + Tag[] tags1 = { new() { Text = "tag1", Group = "test" }, new() { Text = "Tag1", Group = "test" } }; + repository.Assign( + content1.Id, + contentType.PropertyTypes.First().Id, + tags1, + false); + + // Note the casing is different from the tag in tags1, but both should be considered equivalent. + Tag[] tags2 = { new() { Text = "TAG1", Group = "test" } }; + repository.Assign( + content2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + // Only one tag should have been saved. + var tagCount = scope.Database.ExecuteScalar( + "SELECT COUNT(*) FROM cmsTags WHERE [group] = 'test'"); + Assert.AreEqual(1, tagCount); + + // Both content items should be found as tagged by the tag, even though one was assigned with the tag differing in case. + Assert.AreEqual(2, repository.GetTaggedEntitiesByTag(TaggableObjectTypes.Content, "tag1").Count()); + } + } + + [Test] + public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Group() + { + var provider = ScopeProvider; + using (var scope = ScopeProvider.CreateScope()) + { + (IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests(); + + var repository = CreateRepository(provider); + + // Note two tags are applied, but they differ only in case for the group. + Tag[] tags1 = { new() { Text = "tag1", Group = "group1" }, new() { Text = "tag1", Group = "Group1" } }; + repository.Assign( + content1.Id, + contentType.PropertyTypes.First().Id, + tags1, + false); + + // Note the casing is different from the group in tags1, but both should be considered equivalent. + Tag[] tags2 = { new() { Text = "tag1", Group = "GROUP1" } }; + repository.Assign( + content2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + // Only one tag/group should have been saved. + var tagCount = scope.Database.ExecuteScalar( + "SELECT COUNT(*) FROM cmsTags WHERE [tag] = 'tag1'"); + Assert.AreEqual(1, tagCount); + + var groupCount = scope.Database.ExecuteScalar( + "SELECT COUNT(*) FROM cmsTags WHERE [group] = 'group1'"); + Assert.AreEqual(1, groupCount); + + // Both content items should be found as tagged by the tag, even though one was assigned with the group differing in case. + Assert.AreEqual(2, repository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Content, "group1").Count()); + } + } + + private (IContentType ContentType, IContent Content1, IContent Content2) CreateContentForCreateTagTests() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + FileService.SaveTemplate(template); + + var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id); + ContentTypeRepository.Save(contentType); + + var content1 = ContentBuilder.CreateSimpleContent(contentType); + var content2 = ContentBuilder.CreateSimpleContent(contentType); + DocumentRepository.Save(content1); + DocumentRepository.Save(content2); + + return (contentType, content1, content2); + } + private TagRepository CreateRepository(IScopeProvider provider) => new((IScopeAccessor)provider, AppCaches.Disabled, LoggerFactory.CreateLogger()); }