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 <175728472+Copilot@users.noreply.github.com>

* Handle case sensitivity on insert with tag groups too.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Butland
2025-05-30 09:05:22 +02:00
parent c4be7842a7
commit fd3808ae38
2 changed files with 104 additions and 5 deletions

View File

@@ -128,10 +128,13 @@ internal class TagRepository : EntityRepositoryBase<int, ITag>, 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;
}

View File

@@ -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<int>(
"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<int>(
"SELECT COUNT(*) FROM cmsTags WHERE [tag] = 'tag1'");
Assert.AreEqual(1, tagCount);
var groupCount = scope.Database.ExecuteScalar<int>(
"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<TagRepository>());
}