From 0858d02172ae5dc5c1f633ff487ec1aab77ba310 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 11 Nov 2025 12:43:03 +0100 Subject: [PATCH] Single block migration (#20663) * WiP blocklist migration * Mostly working migration * [WIP] deconstructed the migration to prefetch and process all data that requires the old definitions * Working singleblock migration * Abstracted some logic and applied it to settings elements too. * Align class and file name. * Minor code warning resolution. * More and better comments + made classes internal where it made sense --------- Co-authored-by: Andy Butland --- .../BlockEditorElementTypeCache.cs | 10 +- .../IBlockEditorElementTypeCache.cs | 1 + .../Migrations/Upgrade/UmbracoPlan.cs | 4 + .../V_18_0_0/MigrateSingleBlockList.cs | 436 ++++++++++++++++++ .../ITypedSingleBlockListProcessor.cs | 23 + .../MigrateSingleBlockListComposer.cs | 18 + .../SingleBlockBlockProcessorBase.cs | 41 ++ .../SingleBlockListBlockGridProcessor.cs | 50 ++ .../SingleBlockListBlockListProcessor.cs | 52 +++ .../SingleBlockListConfigurationCache.cs | 52 +++ .../SingleBlockListProcessor.cs | 51 ++ .../SingleBlockListRteProcessor.cs | 58 +++ 12 files changed, 792 insertions(+), 4 deletions(-) create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs diff --git a/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs b/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs index 14dc4db632..f47501c94b 100644 --- a/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs +++ b/src/Umbraco.Infrastructure/Cache/PropertyEditors/BlockEditorElementTypeCache.cs @@ -6,9 +6,11 @@ namespace Umbraco.Cms.Core.Cache.PropertyEditors; internal sealed class BlockEditorElementTypeCache : IBlockEditorElementTypeCache { + private const string CacheKey = $"{nameof(BlockEditorElementTypeCache)}_ElementTypes"; private readonly IContentTypeService _contentTypeService; private readonly AppCaches _appCaches; + public BlockEditorElementTypeCache(IContentTypeService contentTypeService, AppCaches appCaches) { _contentTypeService = contentTypeService; @@ -20,15 +22,15 @@ internal sealed class BlockEditorElementTypeCache : IBlockEditorElementTypeCache public IEnumerable GetAll() { // TODO: make this less dumb; don't fetch all elements, only fetch the items that aren't yet in the cache and amend the cache as more elements are loaded - - const string cacheKey = $"{nameof(BlockEditorElementTypeCache)}_ElementTypes"; - IEnumerable? cachedElements = _appCaches.RequestCache.GetCacheItem>(cacheKey); + IEnumerable? cachedElements = _appCaches.RequestCache.GetCacheItem>(CacheKey); if (cachedElements is null) { cachedElements = _contentTypeService.GetAllElementTypes(); - _appCaches.RequestCache.Set(cacheKey, cachedElements); + _appCaches.RequestCache.Set(CacheKey, cachedElements); } return cachedElements; } + + public void ClearAll() => _appCaches.RequestCache.Remove(CacheKey); } diff --git a/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs b/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs index f99d2ff875..48142de97f 100644 --- a/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs +++ b/src/Umbraco.Infrastructure/Cache/PropertyEditors/IBlockEditorElementTypeCache.cs @@ -6,4 +6,5 @@ public interface IBlockEditorElementTypeCache { IEnumerable GetMany(IEnumerable keys); IEnumerable GetAll(); + void ClearAll() { } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 7a781b889a..8bdddc0a55 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -139,5 +139,9 @@ public class UmbracoPlan : MigrationPlan To("{263075BF-F18A-480D-92B4-4947D2EAB772}"); To("26179D88-58CE-4C92-B4A4-3CBA6E7188AC"); To("{8B2C830A-4FFB-4433-8337-8649B0BF52C8}"); + + // To 18.0.0 + // TODO (V18): Enable on 18 branch + //// To("{74332C49-B279-4945-8943-F8F00B1F5949}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs new file mode 100644 index 0000000000..23d683ee01 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/MigrateSingleBlockList.cs @@ -0,0 +1,436 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0; + +public class MigrateSingleBlockList : AsyncMigrationBase +{ + private readonly IUmbracoContextFactory _umbracoContextFactory; + private readonly ILanguageService _languageService; + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IDataTypeService _dataTypeService; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly SingleBlockListProcessor _singleBlockListProcessor; + private readonly IJsonSerializer _jsonSerializer; + private readonly SingleBlockListConfigurationCache _blockListConfigurationCache; + private readonly IBlockEditorElementTypeCache _elementTypeCache; + private readonly AppCaches _appCaches; + private readonly ILogger _logger; + private readonly IDataValueEditor _dummySingleBlockValueEditor; + + public MigrateSingleBlockList( + IMigrationContext context, + IUmbracoContextFactory umbracoContextFactory, + ILanguageService languageService, + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IDataTypeService dataTypeService, + ILogger logger, + ICoreScopeProvider coreScopeProvider, + SingleBlockListProcessor singleBlockListProcessor, + IJsonSerializer jsonSerializer, + SingleBlockListConfigurationCache blockListConfigurationCache, + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory, + IBlockEditorElementTypeCache elementTypeCache, + AppCaches appCaches) + : base(context) + { + _umbracoContextFactory = umbracoContextFactory; + _languageService = languageService; + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _dataTypeService = dataTypeService; + _logger = logger; + _coreScopeProvider = coreScopeProvider; + _singleBlockListProcessor = singleBlockListProcessor; + _jsonSerializer = jsonSerializer; + _blockListConfigurationCache = blockListConfigurationCache; + _elementTypeCache = elementTypeCache; + _appCaches = appCaches; + + _dummySingleBlockValueEditor = new SingleBlockPropertyEditor(dataValueEditorFactory, jsonSerializer, ioHelper, blockValuePropertyIndexValueFactory).GetValueEditor(); + } + + protected override async Task MigrateAsync() + { + // gets filled by all registered ITypedSingleBlockListProcessor + IEnumerable propertyEditorAliases = _singleBlockListProcessor.GetSupportedPropertyEditorAliases(); + + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + var languagesById = (await _languageService.GetAllAsync()) + .ToDictionary(language => language.Id); + + IEnumerable allContentTypes = _contentTypeService.GetAll(); + IEnumerable contentPropertyTypes = allContentTypes + .SelectMany(ct => ct.PropertyTypes); + + IMediaType[] allMediaTypes = _mediaTypeService.GetAll().ToArray(); + IEnumerable mediaPropertyTypes = allMediaTypes + .SelectMany(ct => ct.PropertyTypes); + + // get all relevantPropertyTypes + var relevantPropertyEditors = + contentPropertyTypes.Concat(mediaPropertyTypes).DistinctBy(pt => pt.Id) + .Where(pt => propertyEditorAliases.Contains(pt.PropertyEditorAlias)) + .GroupBy(pt => pt.PropertyEditorAlias) + .ToDictionary(group => group.Key, group => group.ToArray()); + + // populate the cache to limit amount of db locks in recursion logic. + var blockListsConfiguredAsSingleCount = await _blockListConfigurationCache.Populate(); + + if (blockListsConfiguredAsSingleCount == 0) + { + _logger.LogInformation( + "No blocklist were configured as single, nothing to do."); + return; + } + + _logger.LogInformation( + "Found {blockListsConfiguredAsSingleCount} number of blockListConfigurations with UseSingleBlockMode set to true", + blockListsConfiguredAsSingleCount); + + // we want to batch actual update calls to the database, so we are grouping them by propertyEditorAlias + // and again by propertyType(dataType). + var updateItemsByPropertyEditorAlias = new Dictionary>>(); + + // For each propertyEditor, collect and process all propertyTypes and their propertyData + foreach (var propertyEditorAlias in propertyEditorAliases) + { + if (relevantPropertyEditors.TryGetValue(propertyEditorAlias, out IPropertyType[]? propertyTypes) is false) + { + continue; + } + + _logger.LogInformation( + "Migration starting for all properties of type: {propertyEditorAlias}", + propertyEditorAlias); + Dictionary> updateItemsByPropertyType = await ProcessPropertyTypesAsync(propertyTypes, languagesById); + if (updateItemsByPropertyType.Count < 1) + { + _logger.LogInformation( + "No properties have been found to migrate for {propertyEditorAlias}", + propertyEditorAlias); + return; + } + + updateItemsByPropertyEditorAlias[propertyEditorAlias] = updateItemsByPropertyType; + } + + // update the configuration of all propertyTypes + var singleBlockListDataTypesIds = _blockListConfigurationCache.CachedDataTypes.ToList().Select(type => type.Id).ToList(); + + string updateSql = $@" +UPDATE umbracoDataType +SET propertyEditorAlias = '{Constants.PropertyEditors.Aliases.SingleBlock}', + propertyEditorUiAlias = 'Umb.PropertyEditorUi.SingleBlock' +WHERE nodeId IN (@0)"; + await Database.ExecuteAsync(updateSql, singleBlockListDataTypesIds); + + // we need to clear the elementTypeCache so the second part of the migration can work with the update dataTypes + // and also the isolated/runtime Caches as that is what its build from in the default implementation + _elementTypeCache.ClearAll(); + _appCaches.IsolatedCaches.ClearAllCaches(); + _appCaches.RuntimeCache.Clear(); + RebuildCache = true; + + // now that we have updated the configuration of all propertyTypes, we can save the updated propertyTypes + foreach (string propertyEditorAlias in updateItemsByPropertyEditorAlias.Keys) + { + if (await SavePropertyTypes(updateItemsByPropertyEditorAlias[propertyEditorAlias])) + { + _logger.LogInformation( + "Migration succeeded for all properties of type: {propertyEditorAlias}", + propertyEditorAlias); + } + else + { + _logger.LogError( + "Migration failed for one or more properties of type: {propertyEditorAlias}", + propertyEditorAlias); + } + } + } + + private async Task>> ProcessPropertyTypesAsync(IPropertyType[] propertyTypes, IDictionary languagesById) + { + var updateItemsByPropertyType = new Dictionary>(); + foreach (IPropertyType propertyType in propertyTypes) + { + // make sure the passed in data is valid and can be processed + IDataType dataType = await _dataTypeService.GetAsync(propertyType.DataTypeKey) + ?? throw new InvalidOperationException("The data type could not be fetched."); + IDataValueEditor valueEditor = dataType.Editor?.GetValueEditor() + ?? throw new InvalidOperationException( + "The data type value editor could not be obtained."); + + // fetch all the propertyData for the current propertyType + Sql sql = Sql() + .Select() + .From() + .InnerJoin() + .On((propertyData, contentVersion) => + propertyData.VersionId == contentVersion.Id) + .LeftJoin() + .On((contentVersion, documentVersion) => + contentVersion.Id == documentVersion.Id) + .Where((propertyData, contentVersion, documentVersion) => + (contentVersion.Current == true || documentVersion.Published == true) + && propertyData.PropertyTypeId == propertyType.Id); + + List propertyDataDtos = await Database.FetchAsync(sql); + if (propertyDataDtos.Count < 1) + { + continue; + } + + var updateItems = new List(); + + // process all the propertyData + // if none of the processors modify the value, the propertyData is skipped from being saved. + foreach (PropertyDataDto propertyDataDto in propertyDataDtos) + { + if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor, out UpdateItem? updateItem) is false) + { + continue; + } + + updateItems.Add(updateItem!); + } + + updateItemsByPropertyType[propertyType] = updateItems; + } + + return updateItemsByPropertyType; + } + + private async Task SavePropertyTypes(IDictionary> propertyTypes) + { + foreach (IPropertyType propertyType in propertyTypes.Keys) + { + // The dataType and valueEditor should be constructed as we have done this before, but we hate null values. + IDataType dataType = await _dataTypeService.GetAsync(propertyType.DataTypeKey) + ?? throw new InvalidOperationException("The data type could not be fetched."); + IDataValueEditor updatedValueEditor = dataType.Editor?.GetValueEditor() + ?? throw new InvalidOperationException( + "The data type value editor could not be obtained."); + + // batch by datatype + var propertyDataDtos = propertyTypes[propertyType].Select(item => item.PropertyDataDto).ToList(); + + var updateBatch = propertyDataDtos.Select(propertyDataDto => + UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))).ToList(); + + var updatesToSkip = new ConcurrentBag>(); + + var progress = 0; + + void HandleUpdateBatch(UpdateBatch update) + { + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + + progress++; + if (progress % 100 == 0) + { + _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, updateBatch.Count); + } + + PropertyDataDto propertyDataDto = update.Poco; + + if (FinalizeUpdateItem(propertyTypes[propertyType].First(item => Equals(item.PropertyDataDto, update.Poco)), updatedValueEditor) is false) + { + updatesToSkip.Add(update); + } + } + + if (DatabaseType == DatabaseType.SQLite) + { + // SQLite locks up if we run the migration in parallel, so... let's not. + foreach (UpdateBatch update in updateBatch) + { + HandleUpdateBatch(update); + } + } + else + { + Parallel.ForEachAsync(updateBatch, async (update, token) => + { + //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + HandleUpdateBatch(update); + }, + token); + } + + await task; + }).GetAwaiter().GetResult(); + } + + updateBatch.RemoveAll(updatesToSkip.Contains); + + if (updateBatch.Any() is false) + { + _logger.LogDebug(" - no properties to convert, continuing"); + continue; + } + + _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatch.Count); + var result = Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 }); + if (result != updateBatch.Count) + { + throw new InvalidOperationException( + $"The database batch update was supposed to update {updateBatch.Count} property DTO entries, but it updated {result} entries."); + } + + _logger.LogDebug( + "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", + propertyType.Name, + propertyType.Id, + propertyType.Alias, + propertyType.PropertyEditorAlias, + result); + } + + return true; + } + + private bool ProcessPropertyDataDto( + PropertyDataDto propertyDataDto, + IPropertyType propertyType, + IDictionary languagesById, + IDataValueEditor valueEditor, + out UpdateItem? updateItem) + { + // NOTE: some old property data DTOs can have variance defined, even if the property type no longer varies + var culture = propertyType.VariesByCulture() + && propertyDataDto.LanguageId.HasValue + && languagesById.TryGetValue(propertyDataDto.LanguageId.Value, out ILanguage? language) + ? language.IsoCode + : null; + + if (culture is null && propertyType.VariesByCulture()) + { + // if we end up here, the property DTO is bound to a language that no longer exists. this is an error scenario, + // and we can't really handle it in any other way than logging; in all likelihood this is an old property version, + // and it won't cause any runtime issues + _logger.LogWarning( + " - property data with id: {propertyDataId} references a language that does not exist - language id: {languageId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + propertyDataDto.LanguageId, + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updateItem = null; + return false; + } + + // create a fake property to be able to get a typed value and run it trough the processors. + var segment = propertyType.VariesBySegment() ? propertyDataDto.Segment : null; + var property = new Property(propertyType); + property.SetValue(propertyDataDto.Value, culture, segment); + var toEditorValue = valueEditor.ToEditor(property, culture, segment); + + if (TryTransformValue(toEditorValue, property, out var updatedValue) is false) + { + _logger.LogDebug( + " - skipping as no processor modified the data for property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updateItem = null; + return false; + } + + updateItem = new UpdateItem(propertyDataDto, propertyType, updatedValue); + return true; + } + + /// + /// Takes the updated value that was instanced from the db value by the old ValueEditors + /// And runs it through the updated ValueEditors and sets it on the PropertyDataDto + /// + private bool FinalizeUpdateItem(UpdateItem updateItem, IDataValueEditor updatedValueEditor) + { + var editorValue = _jsonSerializer.Serialize(updateItem.UpdatedValue); + var dbValue = updateItem.UpdatedValue is SingleBlockValue + ? _dummySingleBlockValueEditor.FromEditor(new ContentPropertyData(editorValue, null), null) + : updatedValueEditor.FromEditor(new ContentPropertyData(editorValue, null), null); + if (dbValue is not string stringValue || stringValue.DetectIsJson() is false) + { + _logger.LogWarning( + " - value editor did not yield a valid JSON string as FromEditor value property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + updateItem.PropertyDataDto.Id, + updateItem.PropertyType.Name, + updateItem.PropertyType.Id, + updateItem.PropertyType.Alias); + return false; + } + + updateItem.PropertyDataDto.TextValue = stringValue; + return true; + } + + /// + /// If the value is a BlockListValue, and its datatype is configured as single + /// We also need to convert the outer BlockListValue to a SingleBlockValue + /// Either way, we need to run the value through the processors to possibly update nested values + /// + private bool TryTransformValue(object? toEditorValue, Property property, out object? value) + { + bool hasChanged = _singleBlockListProcessor.ProcessToEditorValue(toEditorValue); + + if (toEditorValue is BlockListValue blockListValue + && _blockListConfigurationCache.IsPropertyEditorBlockListConfiguredAsSingle(property.PropertyType.DataTypeKey)) + { + value = _singleBlockListProcessor.ConvertBlockListToSingleBlock(blockListValue); + return true; + } + + value = toEditorValue; + return hasChanged; + } + + private class UpdateItem + { + public UpdateItem(PropertyDataDto propertyDataDto, IPropertyType propertyType, object? updatedValue) + { + PropertyDataDto = propertyDataDto; + PropertyType = propertyType; + UpdatedValue = updatedValue; + } + + public object? UpdatedValue { get; set; } + + public PropertyDataDto PropertyDataDto { get; set; } + + public IPropertyType PropertyType { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs new file mode 100644 index 0000000000..7ad85505d9 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/ITypedSingleBlockListProcessor.cs @@ -0,0 +1,23 @@ +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public interface ITypedSingleBlockListProcessor +{ + /// + /// The type of the propertyEditor expects to receive as a value to process + /// + public Type PropertyEditorValueType { get; } + + /// + /// The property (data)editor aliases that this processor supports, as defined on their DataEditor attributes + /// + public IEnumerable PropertyEditorAliases { get; } + + /// + /// object?: the editorValue being processed + /// Func: the function that will be called when nested content is detected + /// + public Func, Func, bool> Process { get; } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs new file mode 100644 index 0000000000..367cb3af3d --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/MigrateSingleBlockListComposer.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class MigrateSingleBlockListComposer : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs new file mode 100644 index 0000000000..ce88ef01e8 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockBlockProcessorBase.cs @@ -0,0 +1,41 @@ +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal abstract class SingleBlockBlockProcessorBase +{ + private readonly SingleBlockListConfigurationCache _blockListConfigurationCache; + + public SingleBlockBlockProcessorBase( + SingleBlockListConfigurationCache blockListConfigurationCache) + { + _blockListConfigurationCache = blockListConfigurationCache; + } + + protected bool ProcessBlockItemDataValues( + BlockItemData blockItemData, + Func processNested, + Func processOuterValue) + { + var hasChanged = false; + + foreach (BlockPropertyValue blockPropertyValue in blockItemData.Values) + { + if (processNested.Invoke(blockPropertyValue.Value)) + { + hasChanged = true; + } + + if (_blockListConfigurationCache.IsPropertyEditorBlockListConfiguredAsSingle( + blockPropertyValue.PropertyType!.DataTypeKey) + && blockPropertyValue.Value is BlockListValue blockListValue) + { + blockPropertyValue.Value = processOuterValue.Invoke(blockListValue); + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs new file mode 100644 index 0000000000..f84077a47e --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockGridProcessor.cs @@ -0,0 +1,50 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListBlockGridProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListBlockGridProcessor(SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(BlockGridValue); + + public IEnumerable PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockGrid]; + + public Func,Func, bool> Process => ProcessBlocks; + + private bool ProcessBlocks( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not BlockGridValue blockValue) + { + return false; + } + + bool hasChanged = false; + + foreach (BlockItemData contentData in blockValue.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in blockValue.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs new file mode 100644 index 0000000000..71c7f65feb --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListBlockListProcessor.cs @@ -0,0 +1,52 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListBlockListProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListBlockListProcessor( + SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(BlockListValue); + + public IEnumerable PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockList]; + + public Func, Func, bool> Process => ProcessBlocks; + + private bool ProcessBlocks( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not BlockListValue blockValue) + { + return false; + } + + bool hasChanged = false; + + // there might be another list inside the single list so more recursion, yeeey! + foreach (BlockItemData contentData in blockValue.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in blockValue.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs new file mode 100644 index 0000000000..7e342b3de7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListConfigurationCache.cs @@ -0,0 +1,52 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +/// +/// Used by the SingleBlockList Migration and its processors to avoid having to fetch (and thus lock) +/// data from the db multiple times during the migration. +/// +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public class SingleBlockListConfigurationCache +{ + private readonly IDataTypeService _dataTypeService; + private readonly List _singleBlockListDataTypes = new(); + + public SingleBlockListConfigurationCache(IDataTypeService dataTypeService) + { + _dataTypeService = dataTypeService; + } + + /// + /// Populates a cache that holds all the property editor aliases that have a BlockList configuration with UseSingleBlockMode set to true. + /// + /// The number of blocklists with UseSingleBlockMode set to true. + public async Task Populate() + { + IEnumerable blockListDataTypes = + await _dataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.BlockList); + + foreach (IDataType dataType in blockListDataTypes) + { + if (dataType.ConfigurationObject is BlockListConfiguration + { + UseSingleBlockMode: true, ValidationLimit.Max: 1 + }) + { + _singleBlockListDataTypes.Add(dataType); + } + } + + return _singleBlockListDataTypes.Count; + } + + // returns whether the passed in key belongs to a blocklist with UseSingleBlockMode set to true + public bool IsPropertyEditorBlockListConfiguredAsSingle(Guid key) => + _singleBlockListDataTypes.Any(dt => dt.Key == key); + + // The list of all blocklist data types that have UseSingleBlockMode set to true + public IEnumerable CachedDataTypes => _singleBlockListDataTypes.AsReadOnly(); +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs new file mode 100644 index 0000000000..ae13717f8f --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListProcessor.cs @@ -0,0 +1,51 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // Available in v17, activated in v18. Migration needs to work on LTS to LTS 17=>21 +public class SingleBlockListProcessor +{ + private readonly IEnumerable _processors; + + public SingleBlockListProcessor(IEnumerable processors) => _processors = processors; + + public IEnumerable GetSupportedPropertyEditorAliases() => + _processors.SelectMany(p => p.PropertyEditorAliases); + /// + /// The entry point of the recursive conversion + /// Find the first processor that can handle the value and call it's Process method + /// + /// Whether the value was changed + public bool ProcessToEditorValue(object? editorValue) + { + ITypedSingleBlockListProcessor? processor = + _processors.FirstOrDefault(p => p.PropertyEditorValueType == editorValue?.GetType()); + + return processor is not null && processor.Process.Invoke(editorValue, ProcessToEditorValue, ConvertBlockListToSingleBlock); + } + + /// + /// Updates and returns the passed in BlockListValue to a SingleBlockValue + /// Should only be called by a core processor once a BlockListValue has been found that is configured in single block mode. + /// + public BlockValue ConvertBlockListToSingleBlock(BlockListValue blockListValue) + { + IBlockLayoutItem blockListLayoutItem = blockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].First(); + + var singleBlockLayoutItem = new SingleBlockLayoutItem + { + ContentKey = blockListLayoutItem.ContentKey, + SettingsKey = blockListLayoutItem.SettingsKey, + }; + + var singleBlockValue = new SingleBlockValue(singleBlockLayoutItem) + { + ContentData = blockListValue.ContentData, + SettingsData = blockListValue.SettingsData, + Expose = blockListValue.Expose, + }; + + return singleBlockValue; + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs new file mode 100644 index 0000000000..88de3b839b --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_18_0_0/SingleBlockList/SingleBlockListRteProcessor.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_18_0_0.SingleBlockList; + +[Obsolete("Will be removed in V22")] // available in v17, activated in v18 migration needs to work on LTS to LTS 17=>21 +internal class SingleBlockListRteProcessor : SingleBlockBlockProcessorBase, ITypedSingleBlockListProcessor +{ + public SingleBlockListRteProcessor(SingleBlockListConfigurationCache blockListConfigurationCache) + : base(blockListConfigurationCache) + { + } + + public Type PropertyEditorValueType => typeof(RichTextEditorValue); + + public IEnumerable PropertyEditorAliases => + [ + "Umbraco.TinyMCE", Constants.PropertyEditors.Aliases.RichText + ]; + + public Func,Func, bool> Process => ProcessRichText; + + public bool ProcessRichText( + object? value, + Func processNested, + Func processOuterValue) + { + if (value is not RichTextEditorValue richTextValue) + { + return false; + } + + var hasChanged = false; + + if (richTextValue.Blocks is null) + { + return hasChanged; + } + + foreach (BlockItemData contentData in richTextValue.Blocks.ContentData) + { + if (ProcessBlockItemDataValues(contentData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + foreach (BlockItemData settingsData in richTextValue.Blocks.SettingsData) + { + if (ProcessBlockItemDataValues(settingsData, processNested, processOuterValue)) + { + hasChanged = true; + } + } + + return hasChanged; + } +}