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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user