Merge branch 'contrib' into v15/dev

This commit is contained in:
Niels Lyngsø
2025-03-28 12:57:55 +01:00
10 changed files with 363 additions and 41 deletions

View File

@@ -1,3 +1,5 @@
using System.Runtime.InteropServices;
namespace Umbraco.Cms.Core.Persistence.Repositories;
/// <summary>
@@ -5,15 +7,34 @@ namespace Umbraco.Cms.Core.Persistence.Repositories;
/// </summary>
public static class RepositoryCacheKeys
{
// used to cache keys so we don't keep allocating strings
/// <summary>
/// A cache for the keys we don't keep allocating strings.
/// </summary>
private static readonly Dictionary<Type, string> Keys = new();
/// <summary>
/// Gets the repository cache key for the provided type.
/// </summary>
public static string GetKey<T>()
{
Type type = typeof(T);
return Keys.TryGetValue(type, out var key) ? key : Keys[type] = "uRepo_" + type.Name + "_";
// The following code is a micro-optimization to avoid an unnecessary lookup in the Keys dictionary, when writing the newly created key.
// Previously, the code was:
// return Keys.TryGetValue(type, out var key)
// ? key
// : Keys[type] = "uRepo_" + type.Name + "_";
// Look up the existing value or get a reference to the newly created default value.
ref string? key = ref CollectionsMarshal.GetValueRefOrAddDefault(Keys, type, out _);
// As we have the reference, we can just assign it if null, without the expensive write back to the dictionary.
return key ??= "uRepo_" + type.Name + "_";
}
/// <summary>
/// Gets the repository cache key for the provided type and Id.
/// </summary>
public static string GetKey<T, TId>(TId? id)
{
if (EqualityComparer<TId?>.Default.Equals(id, default))

View File

@@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Collections;
@@ -7,7 +8,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Scoping;
/// <summary>
/// Mechanism for handling read and write locks
/// Mechanism for handling read and write locks.
/// </summary>
public class LockingMechanism : ILockingMechanism
{
@@ -189,24 +190,43 @@ public class LockingMechanism : ILockingMechanism
/// <param name="lockId">Lock ID to increment.</param>
/// <param name="instanceId">Instance ID of the scope requesting the lock.</param>
/// <param name="locks">Reference to the dictionary to increment on</param>
private void IncrementLock(int lockId, Guid instanceId, ref Dictionary<Guid, Dictionary<int, int>>? locks)
/// <remarks>Internal for tests.</remarks>
internal static void IncrementLock(int lockId, Guid instanceId, ref Dictionary<Guid, Dictionary<int, int>>? locks)
{
// Since we've already checked that we're the parent in the WriteLockInner method, we don't need to check again.
// If it's the very first time a lock has been requested the WriteLocks dict hasn't been instantiated yet.
locks ??= new Dictionary<Guid, Dictionary<int, int>>();
// If it's the very first time a lock has been requested the WriteLocks dictionary hasn't been instantiated yet.
locks ??= [];
// Try and get the dict associated with the scope id.
var locksDictFound = locks.TryGetValue(instanceId, out Dictionary<int, int>? locksDict);
// Try and get the dictionary associated with the scope id.
// The following code is a micro-optimization.
// GetValueRefOrAddDefault does lookup or creation with only one hash key generation, internal bucket lookup and value lookup in the bucket.
// This compares to doing it twice when initializing, one for the lookup and one for the insertion of the initial value, we had with the
// previous code:
// var locksDictFound = locks.TryGetValue(instanceId, out Dictionary<int, int>? locksDict);
// if (locksDictFound)
// {
// locksDict!.TryGetValue(lockId, out var value);
// locksDict[lockId] = value + 1;
// }
// else
// {
// // The scope hasn't requested a lock yet, so we have to create a dict for it.
// locks.Add(instanceId, new Dictionary<int, int>());
// locks[instanceId][lockId] = 1;
// }
ref Dictionary<int, int>? locksDict = ref CollectionsMarshal.GetValueRefOrAddDefault(locks, instanceId, out bool locksDictFound);
if (locksDictFound)
{
locksDict!.TryGetValue(lockId, out var value);
locksDict[lockId] = value + 1;
// By getting a reference to any existing or default 0 value, we can increment it without the expensive write back into the dictionary.
ref int value = ref CollectionsMarshal.GetValueRefOrAddDefault(locksDict!, lockId, out _);
value++;
}
else
{
// The scope hasn't requested a lock yet, so we have to create a dict for it.
locks.Add(instanceId, new Dictionary<int, int>());
locks[instanceId][lockId] = 1;
// The scope hasn't requested a lock yet, so we have to create a dictionary for it.
locksDict = new Dictionary<int, int> { { lockId, 1 } };
}
}

View File

@@ -184,6 +184,12 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
foreach (BlockItemData item in items)
{
// if changes were made to the element type variations, we need those changes reflected in the block property values.
// for regular content this happens when a content type is saved (copies of property values are created in the DB),
// but for local block level properties we don't have that kind of handling, so we to do it manually.
// to be friendly we'll map "formerly invariant properties" to the default language ISO code instead of performing a
// hard reset of the property values (which would likely be the most correct thing to do from a data point of view).
item.Values = _blockEditorVarianceHandler.AlignPropertyVarianceAsync(item.Values, culture).GetAwaiter().GetResult();
foreach (BlockPropertyValue blockPropertyValue in item.Values)
{
IPropertyType? propertyType = blockPropertyValue.PropertyType;
@@ -199,13 +205,6 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
continue;
}
// if changes were made to the element type variation, we need those changes reflected in the block property values.
// for regular content this happens when a content type is saved (copies of property values are created in the DB),
// but for local block level properties we don't have that kind of handling, so we to do it manually.
// to be friendly we'll map "formerly invariant properties" to the default language ISO code instead of performing a
// hard reset of the property values (which would likely be the most correct thing to do from a data point of view).
_blockEditorVarianceHandler.AlignPropertyVarianceAsync(blockPropertyValue, propertyType, culture).GetAwaiter().GetResult();
if (!valueEditorsByKey.TryGetValue(propertyType.DataTypeKey, out IDataValueEditor? valueEditor))
{
var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey);

View File

@@ -25,15 +25,7 @@ public sealed class BlockEditorVarianceHandler
_contentTypeService = contentTypeService;
}
/// <summary>
/// Aligns a block property value for variance changes.
/// </summary>
/// <param name="blockPropertyValue">The block property value to align.</param>
/// <param name="propertyType">The underlying property type.</param>
/// <param name="culture">The culture being handled (null if invariant).</param>
/// <remarks>
/// Used for aligning variance changes when editing content.
/// </remarks>
[Obsolete("Please use the method that allows alignment for a collection of values. Scheduled for removal in V17.")]
public async Task AlignPropertyVarianceAsync(BlockPropertyValue blockPropertyValue, IPropertyType propertyType, string? culture)
{
culture ??= await _languageService.GetDefaultIsoCodeAsync();
@@ -45,6 +37,48 @@ public sealed class BlockEditorVarianceHandler
}
}
/// <summary>
/// Aligns a collection of block property values for variance changes.
/// </summary>
/// <param name="blockPropertyValues">The block property values to align.</param>
/// <param name="culture">The culture being handled (null if invariant).</param>
/// <remarks>
/// Used for aligning variance changes when editing content.
/// </remarks>
public async Task<IList<BlockPropertyValue>> AlignPropertyVarianceAsync(IList<BlockPropertyValue> blockPropertyValues, string? culture)
{
var defaultIsoCodeAsync = await _languageService.GetDefaultIsoCodeAsync();
culture ??= defaultIsoCodeAsync;
var valuesToRemove = new List<BlockPropertyValue>();
foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues)
{
IPropertyType? propertyType = blockPropertyValue.PropertyType;
if (propertyType is null)
{
throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(blockPropertyValues));
}
if (propertyType.VariesByCulture() == VariesByCulture(blockPropertyValue))
{
continue;
}
if (propertyType.VariesByCulture() is false && blockPropertyValue.Culture.InvariantEquals(defaultIsoCodeAsync) is false)
{
valuesToRemove.Add(blockPropertyValue);
}
else
{
blockPropertyValue.Culture = propertyType.VariesByCulture()
? culture
: null;
}
}
return blockPropertyValues.Except(valuesToRemove).ToList();
}
/// <summary>
/// Aligns a block property value for variance changes.
/// </summary>
@@ -199,6 +233,8 @@ public sealed class BlockEditorVarianceHandler
blockValue.Expose.Add(new BlockItemVariation(contentData.Key, value.Culture, value.Segment));
}
}
blockValue.Expose = blockValue.Expose.DistinctBy(e => $"{e.ContentKey}.{e.Culture}.{e.Segment}").ToList();
}
private static bool VariesByCulture(BlockPropertyValue blockPropertyValue)

View File

@@ -9,7 +9,7 @@
"@umbraco-cms/backoffice": "15.2.1",
"msw": "^2.7.0",
"typescript": "^5.7.3",
"vite": "^6.2.2",
"vite": "^6.2.3",
"vite-tsconfig-paths": "^5.1.4"
},
"engines": {
@@ -3772,9 +3772,9 @@
}
},
"node_modules/vite": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.2.tgz",
"integrity": "sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -16,7 +16,7 @@
"@umbraco-cms/backoffice": "15.2.1",
"msw": "^2.7.0",
"typescript": "^5.7.3",
"vite": "^6.2.2",
"vite": "^6.2.3",
"vite-tsconfig-paths": "^5.1.4"
},
"msw": {

View File

@@ -185,10 +185,7 @@ public class ContentBuilder
{
if (string.IsNullOrWhiteSpace(name))
{
if (_cultureNames.TryGetValue(culture, out _))
{
_cultureNames.Remove(culture);
}
_cultureNames.Remove(culture);
}
else
{

View File

@@ -1,11 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
@@ -742,6 +741,185 @@ internal partial class BlockListElementLevelVariationTests
}
}
[Test]
public async Task Can_Align_Culture_Variance_For_Variant_Element_Types()
{
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(ContentVariation.Nothing, blockListDataType);
var content = CreateContent(
contentType,
elementType,
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant content value" },
new() { Alias = "variantText", Value = "Another invariant content value" }
},
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant settings value" },
new() { Alias = "variantText", Value = "Another invariant settings value" }
},
false);
contentType.Variations = ContentVariation.Culture;
ContentTypeService.Save(contentType);
// re-fetch content
content = ContentService.GetById(content.Key);
var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor();
var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue;
Assert.IsNotNull(blockListValue);
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.ContentData.Count);
Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count);
var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.AreEqual("en-US", variantValue.Culture);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.SettingsData.Count);
Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count);
var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.AreEqual("en-US", variantValue.Culture);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.Expose.Count);
Assert.AreEqual("en-US", blockListValue.Expose.First().Culture);
});
}
[TestCase(ContentVariation.Culture)]
[TestCase(ContentVariation.Nothing)]
public async Task Can_Turn_Invariant_Element_Variant(ContentVariation contentTypeVariation)
{
var elementType = CreateElementType(ContentVariation.Nothing);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(contentTypeVariation, blockListDataType);
var content = CreateContent(
contentType,
elementType,
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant content value" },
new() { Alias = "variantText", Value = "Another invariant content value" }
},
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant settings value" },
new() { Alias = "variantText", Value = "Another invariant settings value" }
},
false);
elementType.Variations = ContentVariation.Culture;
elementType.PropertyTypes.First(p => p.Alias == "variantText").Variations = ContentVariation.Culture;
ContentTypeService.Save(elementType);
// re-fetch content
content = ContentService.GetById(content.Key);
var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor();
var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue;
Assert.IsNotNull(blockListValue);
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.ContentData.Count);
Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count);
var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.AreEqual("en-US", variantValue.Culture);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.SettingsData.Count);
Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count);
var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.AreEqual("en-US", variantValue.Culture);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.Expose.Count);
Assert.AreEqual("en-US", blockListValue.Expose.First().Culture);
});
}
[TestCase(ContentVariation.Nothing)]
[TestCase(ContentVariation.Culture)]
public async Task Can_Turn_Variant_Element_Invariant(ContentVariation contentTypeVariation)
{
var elementType = CreateElementType(ContentVariation.Culture);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = CreateContentType(contentTypeVariation, blockListDataType);
var content = CreateContent(
contentType,
elementType,
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant content value" },
new() { Alias = "variantText", Value = "Variant content in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "Variant content in Danish", Culture = "da-DK" }
},
new List<BlockPropertyValue>
{
new() { Alias = "invariantText", Value = "The invariant settings value" },
new() { Alias = "variantText", Value = "Variant settings in English", Culture = "en-US" },
new() { Alias = "variantText", Value = "Variant settings in Danish", Culture = "da-DK" }
},
false);
elementType.Variations = ContentVariation.Nothing;
elementType.PropertyTypes.First(p => p.Alias == "variantText").Variations = ContentVariation.Nothing;
ContentTypeService.Save(elementType);
// re-fetch content
content = ContentService.GetById(content.Key);
var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor();
var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue;
Assert.IsNotNull(blockListValue);
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.ContentData.Count);
Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count);
var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.IsNull(variantValue.Culture);
Assert.AreEqual("Variant content in English", variantValue.Value);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.SettingsData.Count);
Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count);
var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText");
var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText");
Assert.IsNull(invariantValue.Culture);
Assert.IsNull(variantValue.Culture);
Assert.AreEqual("Variant settings in English", variantValue.Value);
});
Assert.Multiple(() =>
{
Assert.AreEqual(1, blockListValue.Expose.Count);
Assert.IsNull(blockListValue.Expose.First().Culture);
});
}
private async Task<IUser> CreateLimitedUser()
{
var userGroupService = GetRequiredService<IUserGroupService>();

View File

@@ -0,0 +1,26 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
[TestFixture]
public class RepositoryCacheKeysTests
{
[Test]
public void GetKey_Returns_Expected_Key_For_Type()
{
var key = RepositoryCacheKeys.GetKey<IContent>();
Assert.AreEqual("uRepo_IContent_", key);
}
[Test]
public void GetKey_Returns_Expected_Key_For_Type_And_Id()
{
var key = RepositoryCacheKeys.GetKey<IContent, int>(1000);
Assert.AreEqual("uRepo_IContent_1000", key);
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Core.Scoping;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Scoping;
[TestFixture]
internal class LockingMechanismTests
{
private const int LockId = 1000;
private const int LockId2 = 1001;
private static readonly Guid _scopeInstanceId = Guid.NewGuid();
[Test]
public void IncrementLock_WithoutLocksDictionary_CreatesLock()
{
var locks = new Dictionary<Guid, Dictionary<int, int>>();
LockingMechanism.IncrementLock(LockId, _scopeInstanceId, ref locks);
Assert.AreEqual(1, locks.Count);
Assert.AreEqual(1, locks[_scopeInstanceId][LockId]);
}
[Test]
public void IncrementLock_WithExistingLocksDictionary_CreatesLock()
{
var locks = new Dictionary<Guid, Dictionary<int, int>>()
{
{
_scopeInstanceId,
new Dictionary<int, int>()
{
{ LockId, 100 },
{ LockId2, 200 }
}
}
};
LockingMechanism.IncrementLock(LockId, _scopeInstanceId, ref locks);
Assert.AreEqual(1, locks.Count);
Assert.AreEqual(2, locks[_scopeInstanceId].Count);
Assert.AreEqual(101, locks[_scopeInstanceId][LockId]);
Assert.AreEqual(200, locks[_scopeInstanceId][LockId2]);
}
}