diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 4bc08248d328..602269f6c901 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -284,6 +284,7 @@ stages: jobs: # Integration Tests (SQLite) - job: + timeoutInMinutes: 180 displayName: Integration Tests (SQLite) strategy: matrix: diff --git a/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs b/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs index dc948f36f3a3..74fa71bdd6fb 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Scoping/AmbientEFCoreScopeStack.cs @@ -5,36 +5,45 @@ namespace Umbraco.Cms.Persistence.EFCore.Scoping; public class AmbientEFCoreScopeStack : IAmbientEFCoreScopeStack where TDbContext : DbContext { - + private static Lock _lock = new(); private static AsyncLocal>> _stack = new(); public IEfCoreScope? AmbientScope { get { - if (_stack.Value?.TryPeek(out IEfCoreScope? ambientScope) ?? false) + lock (_lock) { - return ambientScope; - } + if (_stack.Value?.TryPeek(out IEfCoreScope? ambientScope) ?? false) + { + return ambientScope; + } - return null; + return null; + } } } public IEfCoreScope Pop() { - if (_stack.Value?.TryPop(out IEfCoreScope? ambientScope) ?? false) + lock (_lock) { - return ambientScope; - } + if (_stack.Value?.TryPop(out IEfCoreScope? ambientScope) ?? false) + { + return ambientScope; + } - throw new InvalidOperationException("No AmbientScope was found."); + throw new InvalidOperationException("No AmbientScope was found."); + } } public void Push(IEfCoreScope scope) { - _stack.Value ??= new ConcurrentStack>(); + lock (_lock) + { + _stack.Value ??= new ConcurrentStack>(); - _stack.Value.Push(scope); + _stack.Value.Push(scope); + } } } diff --git a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj index 3a914df90461..2464d73cefe6 100644 --- a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj +++ b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj @@ -4,6 +4,7 @@ Contains the static assets needed to run Umbraco CMS. true / + false @@ -76,4 +77,4 @@ - \ No newline at end of file + diff --git a/src/Umbraco.Core/Constants-Composing.cs b/src/Umbraco.Core/Constants-Composing.cs index defdf2fa932e..f5598c1795cf 100644 --- a/src/Umbraco.Core/Constants-Composing.cs +++ b/src/Umbraco.Core/Constants-Composing.cs @@ -12,7 +12,7 @@ public static class Composing { public static readonly string[] UmbracoCoreAssemblyNames = { - "Umbraco.Core", "Umbraco.Infrastructure", "Umbraco.PublishedCache.NuCache", "Umbraco.Examine.Lucene", + "Umbraco.Core", "Umbraco.Infrastructure", "Umbraco.Examine.Lucene", "Umbraco.Web.Common", "Umbraco.Cms.Api.Common","Umbraco.Cms.Api.Delivery","Umbraco.Cms.Api.Management", "Umbraco.Web.Website", }; } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index d702ce479e09..9fb195481f77 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -416,7 +416,8 @@ private void AddCoreServices() // Routing Services.AddUnique(); - Services.AddHostedService(); + Services.AddNotificationAsyncHandler(); + } } } diff --git a/src/Umbraco.Core/Notifications/PostRuntimePremigrationsUpgradeNotification.cs b/src/Umbraco.Core/Notifications/PostRuntimePremigrationsUpgradeNotification.cs new file mode 100644 index 000000000000..2cbea10692ca --- /dev/null +++ b/src/Umbraco.Core/Notifications/PostRuntimePremigrationsUpgradeNotification.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Notifications; + +public class PostRuntimePremigrationsUpgradeNotification : INotification +{ + +} diff --git a/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs index 6442c6686a35..9d96ced00ad6 100644 --- a/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs @@ -1,5 +1,4 @@ using Umbraco.Cms.Core.Models; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -9,11 +8,15 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public class DefaultPropertyIndexValueFactory : IPropertyIndexValueFactory { - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, + public IEnumerable GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures, IDictionary contentTypeDictionary) - { - yield return new KeyValuePair>( - property.Alias, - property.GetValue(culture, segment, published).Yield()); - } + => + [ + new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = [property.GetValue(culture, segment, published)] + } + ]; } diff --git a/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs index ea4fbf614ae4..6b5942ceb78b 100644 --- a/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs @@ -12,9 +12,9 @@ public interface IPropertyIndexValueFactory /// /// /// - /// Returns key-value pairs, where keys are indexed field names. By default, that would be the property alias, - /// and there would be only one pair, but some implementations (see for instance the grid one) may return more than - /// one pair, with different indexed field names. + /// Returns index values for a given property. By default, a property uses its alias as index field name, + /// and there would be only one index value, but some implementations (see for instance the grid one) may return more than + /// one value, with different indexed field names. /// /// /// And then, values are an enumerable of objects, because each indexed field can in turn have multiple @@ -22,7 +22,7 @@ public interface IPropertyIndexValueFactory /// more than one value for a given field. /// /// - IEnumerable>> GetIndexValues( + IEnumerable GetIndexValues( IProperty property, string? culture, string? segment, diff --git a/src/Umbraco.Core/PropertyEditors/IndexValue.cs b/src/Umbraco.Core/PropertyEditors/IndexValue.cs new file mode 100644 index 000000000000..ebcc321bf9c6 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IndexValue.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public sealed class IndexValue +{ + public required string? Culture { get; set; } + + public required string FieldName { get; set; } + + public required IEnumerable Values { get; set; } +} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs index e4a7492c5da1..bfbd38a294b6 100644 --- a/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs +++ b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs @@ -27,7 +27,7 @@ protected JsonPropertyIndexValueFactoryBase(IJsonSerializer jsonSerializer, IOpt indexingSettings.OnChange(newValue => _indexingSettings = newValue); } - public virtual IEnumerable>> GetIndexValues( + public virtual IEnumerable GetIndexValues( IProperty property, string? culture, string? segment, @@ -35,7 +35,7 @@ protected JsonPropertyIndexValueFactoryBase(IJsonSerializer jsonSerializer, IOpt IEnumerable availableCultures, IDictionary contentTypeDictionary) { - var result = new List>>(); + var result = new List(); var propertyValue = property.GetValue(culture, segment, published); @@ -65,7 +65,7 @@ protected JsonPropertyIndexValueFactoryBase(IJsonSerializer jsonSerializer, IOpt } } - IEnumerable>> summary = HandleResume(result, property, culture, segment, published); + IEnumerable summary = HandleResume(result, property, culture, segment, published); if (_indexingSettings.ExplicitlyIndexEachNestedProperty || ForceExplicitlyIndexEachNestedProperty) { result.AddRange(summary); @@ -78,17 +78,17 @@ protected JsonPropertyIndexValueFactoryBase(IJsonSerializer jsonSerializer, IOpt /// /// Method to return a list of summary of the content. By default this returns an empty list /// - protected virtual IEnumerable>> HandleResume( - List>> result, + protected virtual IEnumerable HandleResume( + List result, IProperty property, string? culture, string? segment, - bool published) => Array.Empty>>(); + bool published) => Array.Empty(); /// /// Method that handle the deserialized object. /// - protected abstract IEnumerable>> Handle( + protected abstract IEnumerable Handle( TSerialized deserializedPropertyValue, IProperty property, string? culture, diff --git a/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs index 24711058cde0..61193a5c2647 100644 --- a/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs @@ -8,7 +8,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; public class NoopPropertyIndexValueFactory : IPropertyIndexValueFactory { /// - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, + public IEnumerable GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures, IDictionary contentTypeDictionary) - => Array.Empty>>(); + => []; } diff --git a/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs index 1696c1550ad6..9f3ebb674b9b 100644 --- a/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/TagPropertyIndexValueFactory.cs @@ -19,17 +19,7 @@ public TagPropertyIndexValueFactory( indexingSettings.OnChange(newValue => _indexingSettings = newValue); } - [Obsolete("Use the overload with the 'contentTypeDictionary' parameter instead, scheduled for removal in v15")] - protected IEnumerable>> Handle( - string[] deserializedPropertyValue, - IProperty property, - string? culture, - string? segment, - bool published, - IEnumerable availableCultures) - => Handle(deserializedPropertyValue, property, culture, segment, published, availableCultures, new Dictionary()); - - protected override IEnumerable>> Handle( + protected override IEnumerable Handle( string[] deserializedPropertyValue, IProperty property, string? culture, @@ -37,11 +27,17 @@ public TagPropertyIndexValueFactory( bool published, IEnumerable availableCultures, IDictionary contentTypeDictionary) - { - yield return new KeyValuePair>(property.Alias, deserializedPropertyValue); - } + => + [ + new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = deserializedPropertyValue + } + ]; - public override IEnumerable>> GetIndexValues( + public override IEnumerable GetIndexValues( IProperty property, string? culture, string? segment, @@ -49,13 +45,13 @@ public TagPropertyIndexValueFactory( IEnumerable availableCultures, IDictionary contentTypeDictionary) { - IEnumerable>> jsonValues = base.GetIndexValues(property, culture, segment, published, availableCultures, contentTypeDictionary); + IEnumerable jsonValues = base.GetIndexValues(property, culture, segment, published, availableCultures, contentTypeDictionary); if (jsonValues?.Any() is true) { return jsonValues; } - var result = new List>>(); + var result = new List(); var propertyValue = property.GetValue(culture, segment, published); @@ -67,7 +63,7 @@ public TagPropertyIndexValueFactory( result.AddRange(Handle(values, property, culture, segment, published, availableCultures, contentTypeDictionary)); } - IEnumerable>> summary = HandleResume(result, property, culture, segment, published); + IEnumerable summary = HandleResume(result, property, culture, segment, published); if (_indexingSettings.ExplicitlyIndexEachNestedProperty || ForceExplicitlyIndexEachNestedProperty) { result.AddRange(summary); diff --git a/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs b/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs deleted file mode 100644 index 4c99b9a8ae1c..000000000000 --- a/src/Umbraco.Core/Services/DocumentUrlServiceInitializer.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.Extensions.Hosting; - -namespace Umbraco.Cms.Core.Services; - -public class DocumentUrlServiceInitializer : IHostedLifecycleService -{ - private readonly IDocumentUrlService _documentUrlService; - private readonly IRuntimeState _runtimeState; - - public DocumentUrlServiceInitializer(IDocumentUrlService documentUrlService, IRuntimeState runtimeState) - { - _documentUrlService = documentUrlService; - _runtimeState = runtimeState; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - if (_runtimeState.Level == RuntimeLevel.Upgrade) - { - //Special case on the first upgrade, as the database is not ready yet. - return; - } - - await _documentUrlService.InitAsync( - _runtimeState.Level <= RuntimeLevel.Install, - cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task StartingAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task StartedAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task StoppingAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task StoppedAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/src/Umbraco.Core/Services/DocumentUrlServiceInitializerNotificationHandler.cs b/src/Umbraco.Core/Services/DocumentUrlServiceInitializerNotificationHandler.cs new file mode 100644 index 000000000000..a10843f6e6cd --- /dev/null +++ b/src/Umbraco.Core/Services/DocumentUrlServiceInitializerNotificationHandler.cs @@ -0,0 +1,29 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; + +namespace Umbraco.Cms.Core.Services; + +public class DocumentUrlServiceInitializerNotificationHandler : INotificationAsyncHandler +{ + private readonly IDocumentUrlService _documentUrlService; + private readonly IRuntimeState _runtimeState; + + public DocumentUrlServiceInitializerNotificationHandler(IDocumentUrlService documentUrlService, IRuntimeState runtimeState) + { + _documentUrlService = documentUrlService; + _runtimeState = runtimeState; + } + + public async Task HandleAsync(UmbracoApplicationStartingNotification notification, CancellationToken cancellationToken) + { + if (_runtimeState.Level == RuntimeLevel.Upgrade) + { + //Special case on the first upgrade, as the database is not ready yet. + return; + } + + await _documentUrlService.InitAsync( + _runtimeState.Level <= RuntimeLevel.Install, + cancellationToken); + } +} diff --git a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs index 10b2b6ba1cae..9a19e31dd12b 100644 --- a/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs +++ b/src/Umbraco.Core/Services/Navigation/ContentNavigationServiceBase.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Navigation; using Umbraco.Cms.Core.Persistence.Repositories; @@ -64,12 +65,12 @@ public bool TryGetAncestorsKeysInBin(Guid childKey, out IEnumerable ancest public bool TryGetSiblingsKeysInBin(Guid key, out IEnumerable siblingsKeys) => TryGetSiblingsKeysFromStructure(_recycleBinNavigationStructure, key, out siblingsKeys); - public bool TryGetLevel(Guid contentKey, out int level) + public bool TryGetLevel(Guid contentKey, [NotNullWhen(true)] out int? level) { level = 1; - Guid? parentKey; - if (TryGetParentKey(contentKey, out parentKey) is false) + if (TryGetParentKey(contentKey, out Guid? parentKey) is false) { + level = null; return false; } @@ -77,6 +78,7 @@ public bool TryGetLevel(Guid contentKey, out int level) { if (TryGetParentKey(parentKey.Value, out parentKey) is false) { + level = null; return false; } diff --git a/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs index 7977e0c5dbe4..984bf35efd51 100644 --- a/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs +++ b/src/Umbraco.Core/Services/Navigation/INavigationQueryService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services.Navigation; @@ -44,5 +45,5 @@ bool TryGetAncestorsOrSelfKeys(Guid childKey, out IEnumerable ancestorsOrS bool TryGetSiblingsKeys(Guid key, out IEnumerable siblingsKeys); - bool TryGetLevel(Guid contentKey, out int level); + bool TryGetLevel(Guid contentKey, [NotNullWhen(true)] out int? level); } diff --git a/src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs b/src/Umbraco.Core/Services/Navigation/NavigationInitializationNotificationHandler.cs similarity index 66% rename from src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs rename to src/Umbraco.Core/Services/Navigation/NavigationInitializationNotificationHandler.cs index a11b55b7b840..693c3bf6aa52 100644 --- a/src/Umbraco.Core/Services/Navigation/NavigationInitializationHostedService.cs +++ b/src/Umbraco.Core/Services/Navigation/NavigationInitializationNotificationHandler.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Hosting; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; namespace Umbraco.Cms.Core.Services.Navigation; @@ -6,13 +8,13 @@ namespace Umbraco.Cms.Core.Services.Navigation; /// Responsible for seeding the in-memory navigation structures at application's startup /// by rebuild the navigation structures. /// -public sealed class NavigationInitializationHostedService : IHostedLifecycleService +public sealed class NavigationInitializationNotificationHandler : INotificationAsyncHandler { private readonly IRuntimeState _runtimeState; private readonly IDocumentNavigationManagementService _documentNavigationManagementService; private readonly IMediaNavigationManagementService _mediaNavigationManagementService; - public NavigationInitializationHostedService( + public NavigationInitializationNotificationHandler( IRuntimeState runtimeState, IDocumentNavigationManagementService documentNavigationManagementService, IMediaNavigationManagementService mediaNavigationManagementService) @@ -22,7 +24,7 @@ public NavigationInitializationHostedService( _mediaNavigationManagementService = mediaNavigationManagementService; } - public async Task StartingAsync(CancellationToken cancellationToken) + public async Task HandleAsync(PostRuntimePremigrationsUpgradeNotification notification, CancellationToken cancellationToken) { if(_runtimeState.Level < RuntimeLevel.Upgrade) { @@ -34,14 +36,4 @@ public async Task StartingAsync(CancellationToken cancellationToken) await _mediaNavigationManagementService.RebuildAsync(); await _mediaNavigationManagementService.RebuildBinAsync(); } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs b/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationNotificationHandler.cs similarity index 55% rename from src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs rename to src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationNotificationHandler.cs index b0d3583a6044..f795a49d3240 100644 --- a/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationHostedService.cs +++ b/src/Umbraco.Core/Services/PublishStatus/PublishStatusInitializationNotificationHandler.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Hosting; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; namespace Umbraco.Cms.Core.Services.Navigation; @@ -6,12 +8,12 @@ namespace Umbraco.Cms.Core.Services.Navigation; /// Responsible for seeding the in-memory publish status cache at application's startup /// by loading all data from the database. /// -public sealed class PublishStatusInitializationHostedService : IHostedLifecycleService +public sealed class PublishStatusInitializationNotificationHandler : INotificationAsyncHandler { private readonly IRuntimeState _runtimeState; private readonly IPublishStatusManagementService _publishStatusManagementService; - public PublishStatusInitializationHostedService( + public PublishStatusInitializationNotificationHandler( IRuntimeState runtimeState, IPublishStatusManagementService publishStatusManagementService ) @@ -20,7 +22,7 @@ IPublishStatusManagementService publishStatusManagementService _publishStatusManagementService = publishStatusManagementService; } - public async Task StartingAsync(CancellationToken cancellationToken) + public async Task HandleAsync(PostRuntimePremigrationsUpgradeNotification notification, CancellationToken cancellationToken) { if(_runtimeState.Level < RuntimeLevel.Upgrade) { @@ -29,14 +31,4 @@ public async Task StartingAsync(CancellationToken cancellationToken) await _publishStatusManagementService.InitializeAsync(cancellationToken); } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs index 6c3e6d189ad2..972c58f2f868 100644 --- a/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs @@ -30,18 +30,19 @@ protected void AddPropertyValue(IProperty property, string? culture, string? seg return; } - IEnumerable>> indexVals = + IEnumerable indexVals = editor.PropertyIndexValueFactory.GetIndexValues(property, culture, segment, PublishedValuesOnly, availableCultures, contentTypeDictionary); - foreach (KeyValuePair> keyVal in indexVals) + foreach (IndexValue indexValue in indexVals) { - if (keyVal.Key.IsNullOrWhiteSpace()) + if (indexValue.FieldName.IsNullOrWhiteSpace()) { continue; } - var cultureSuffix = culture == null ? string.Empty : "_" + culture; + var indexValueCulture = indexValue.Culture ?? culture; + var cultureSuffix = indexValueCulture == null ? string.Empty : "_" + indexValueCulture.ToLowerInvariant(); - foreach (var val in keyVal.Value) + foreach (var val in indexValue.Values) { switch (val) { @@ -55,28 +56,28 @@ protected void AddPropertyValue(IProperty property, string? culture, string? seg continue; } - var key = $"{keyVal.Key}{cultureSuffix}"; + var key = $"{indexValue.FieldName}{cultureSuffix}"; if (values?.TryGetValue(key, out IEnumerable? v) ?? false) { values[key] = new List(v) { val }.ToArray(); } else { - values?.Add($"{keyVal.Key}{cultureSuffix}", val.Yield()); + values?.Add($"{indexValue.FieldName}{cultureSuffix}", val.Yield()); } } break; default: { - var key = $"{keyVal.Key}{cultureSuffix}"; + var key = $"{indexValue.FieldName}{cultureSuffix}"; if (values?.TryGetValue(key, out IEnumerable? v) ?? false) { values[key] = new List(v) { val }.ToArray(); } else { - values?.Add($"{keyVal.Key}{cultureSuffix}", val.Yield()); + values?.Add($"{indexValue.FieldName}{cultureSuffix}", val.Yield()); } } diff --git a/src/Umbraco.Infrastructure/Install/PremigrationUpgrader.cs b/src/Umbraco.Infrastructure/Install/PremigrationUpgrader.cs index a3307a347b2d..1be5cb1ee8e7 100644 --- a/src/Umbraco.Infrastructure/Install/PremigrationUpgrader.cs +++ b/src/Umbraco.Infrastructure/Install/PremigrationUpgrader.cs @@ -11,7 +11,7 @@ namespace Umbraco.Cms.Infrastructure.Install; /// -/// Handles to execute the unattended Umbraco upgrader +/// Handles to execute the unattended Umbraco upgrader /// or the unattended Package migrations runner. /// public class PremigrationUpgrader : INotificationAsyncHandler diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 5b09ef3fc2e0..6c4becfc6d25 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -66,14 +66,14 @@ protected virtual void DefinePlan() To("{CC47C751-A81B-489A-A2BC-0240245DB687}"); // To 14.0.0 - To("{419827A0-4FCE-464B-A8F3-247C6092AF55}"); + To("{419827A0-4FCE-464B-A8F3-247C6092AF55}"); To("{69E12556-D9B3-493A-8E8A-65EC89FB658D}"); To("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}"); To("{A8E01644-9F2E-4988-8341-587EF5B7EA69}"); To("{E073DBC0-9E8E-4C92-8210-9CB18364F46E}"); To("{80D282A4-5497-47FF-991F-BC0BCE603121}"); To("{96525697-E9DC-4198-B136-25AD033442B8}"); - To("{7FC5AC9B-6F56-415B-913E-4A900629B853}"); + To("{7FC5AC9B-6F56-415B-913E-4A900629B853}"); To("{1539A010-2EB5-4163-8518-4AE2AA98AFC6}"); To("{C567DE81-DF92-4B99-BEA8-CD34EF99DA5D}"); To("{0D82C836-96DD-480D-A924-7964E458BD34}"); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs index 93fdca4fbb42..d9774aa1ea7a 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPremigrationPlan.cs @@ -65,5 +65,7 @@ protected virtual void DefinePlan() To("{A08254B6-D9E7-4207-A496-2ED0A87FB4FD}"); To("{69AA6889-8B67-42B4-AA4F-114704487A45}"); To("{B9133686-B758-404D-AF12-708AA80C7E44}"); + To("{EEB1F012-B44D-4AB4-8756-F7FB547345B4}"); + To("{0F49E1A4-AFD8-4673-A91B-F64E78C48174}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs index f60c7f049f77..0f1fd3070ad9 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockEditorPropertiesBase.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -23,6 +24,7 @@ public abstract class ConvertBlockEditorPropertiesBase : MigrationBase private readonly IJsonSerializer _jsonSerializer; private readonly IUmbracoContextFactory _umbracoContextFactory; private readonly ILanguageService _languageService; + private readonly ICoreScopeProvider _coreScopeProvider; protected abstract IEnumerable PropertyEditorAliases { get; } @@ -44,7 +46,8 @@ public ConvertBlockEditorPropertiesBase( IDataTypeService dataTypeService, IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, - ILanguageService languageService) + ILanguageService languageService, + ICoreScopeProvider coreScopeProvider) : base(context) { _logger = logger; @@ -53,6 +56,7 @@ public ConvertBlockEditorPropertiesBase( _jsonSerializer = jsonSerializer; _umbracoContextFactory = umbracoContextFactory; _languageService = languageService; + _coreScopeProvider = coreScopeProvider; } protected override void Migrate() @@ -64,13 +68,15 @@ protected override void Migrate() } using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); - var languagesById = _languageService.GetAllAsync().GetAwaiter().GetResult().ToDictionary(language => language.Id); + var languagesById = _languageService.GetAllAsync().GetAwaiter().GetResult() + .ToDictionary(language => language.Id); IContentType[] allContentTypes = _contentTypeService.GetAll().ToArray(); var allPropertyTypesByEditor = allContentTypes .SelectMany(ct => ct.PropertyTypes) .GroupBy(pt => pt.PropertyEditorAlias) .ToDictionary(group => group.Key, group => group.ToArray()); + foreach (var propertyEditorAlias in PropertyEditorAliases) { if (allPropertyTypesByEditor.TryGetValue(propertyEditorAlias, out IPropertyType[]? propertyTypes) is false) @@ -78,14 +84,20 @@ protected override void Migrate() continue; } - _logger.LogInformation("Migration starting for all properties of type: {propertyEditorAlias}", propertyEditorAlias); + _logger.LogInformation( + "Migration starting for all properties of type: {propertyEditorAlias}", + propertyEditorAlias); if (Handle(propertyTypes, languagesById)) { - _logger.LogInformation("Migration succeeded for all properties of type: {propertyEditorAlias}", 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); + _logger.LogError( + "Migration failed for one or more properties of type: {propertyEditorAlias}", + propertyEditorAlias); } } } @@ -98,11 +110,17 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l { var success = true; - foreach (IPropertyType propertyType in propertyTypes) + var propertyTypeCount = propertyTypes.Length; + for (var propertyTypeIndex = 0; propertyTypeIndex < propertyTypeCount; propertyTypeIndex++) { + IPropertyType propertyType = propertyTypes[propertyTypeIndex]; try { - _logger.LogInformation("- starting property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias})...", propertyType.Name, propertyType.Id, propertyType.Alias); + _logger.LogInformation( + "- starting property type {propertyTypeIndex}/{propertyTypeCount} : {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias})...", + propertyTypeIndex + 1, + propertyTypeCount, + propertyType.Name, propertyType.Id, propertyType.Alias); IDataType dataType = _dataTypeService.GetAsync(propertyType.DataTypeKey).GetAwaiter().GetResult() ?? throw new InvalidOperationException("The data type could not be fetched."); @@ -113,7 +131,8 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l } IDataValueEditor valueEditor = dataType.Editor?.GetValueEditor() - ?? throw new InvalidOperationException("The data type value editor could not be fetched."); + ?? throw new InvalidOperationException( + "The data type value editor could not be fetched."); Sql sql = Sql() .Select() @@ -132,109 +151,125 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l var progress = 0; - ExecutionContext.SuppressFlow(); - Parallel.ForEach(updateBatch, update => + Parallel.ForEachAsync(updateBatch, async (update, token) => { - using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); - - progress++; - if (progress % 100 == 0) + //Foreach here, but we need to suppress the flow before each task, but not the actual await of the task + Task task; + using (ExecutionContext.SuppressFlow()) { - _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, updateBatch.Count); - } + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + 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; + PropertyDataDto propertyDataDto = update.Poco; - // 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; + // 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); - return; - } + 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); + return; + } - 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); - switch (toEditorValue) - { - case null: - _logger.LogWarning( - " - value editor yielded a null value for property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", - propertyDataDto.Id, - propertyType.Name, - propertyType.Id, - propertyType.Alias); - updatesToSkip.Add(update); - return; - - case string str when str.IsNullOrWhiteSpace(): - // indicates either an empty block editor or corrupt block editor data - we can't do anything about either here - updatesToSkip.Add(update); - return; - - default: - switch (DetermineEditorValueHandling(toEditorValue)) + 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); + switch (toEditorValue) { - case EditorValueHandling.IgnoreConversion: - // nothing to convert, continue - updatesToSkip.Add(update); - return; - case EditorValueHandling.ProceedConversion: - // continue the conversion - break; - case EditorValueHandling.HandleAsError: - _logger.LogError( - " - value editor did not yield a valid ToEditor value for property data with id: {propertyDataId} - the value type was {valueType} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + case null: + _logger.LogWarning( + " - value editor yielded a null value for property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", propertyDataDto.Id, - toEditorValue.GetType(), propertyType.Name, propertyType.Id, propertyType.Alias); updatesToSkip.Add(update); return; + + case string str when str.IsNullOrWhiteSpace(): + // indicates either an empty block editor or corrupt block editor data - we can't do anything about either here + updatesToSkip.Add(update); + return; + default: - throw new ArgumentOutOfRangeException(); + switch (DetermineEditorValueHandling(toEditorValue)) + { + case EditorValueHandling.IgnoreConversion: + // nothing to convert, continue + updatesToSkip.Add(update); + return; + case EditorValueHandling.ProceedConversion: + // continue the conversion + break; + case EditorValueHandling.HandleAsError: + _logger.LogError( + " - value editor did not yield a valid ToEditor value for property data with id: {propertyDataId} - the value type was {valueType} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + toEditorValue.GetType(), + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updatesToSkip.Add(update); + return; + default: + throw new ArgumentOutOfRangeException(); + } + + break; } - break; - } - toEditorValue = UpdateEditorValue(toEditorValue); + toEditorValue = UpdateEditorValue(toEditorValue); - var editorValue = _jsonSerializer.Serialize(toEditorValue); - var dbValue = valueEditor.FromEditor(new ContentPropertyData(editorValue, null), null); - if (dbValue is not string stringValue || stringValue.DetectIsJson() is false) - { - _logger.LogError( - " - value editor did not yield a valid JSON string as FromEditor value property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", - propertyDataDto.Id, - propertyType.Name, - propertyType.Id, - propertyType.Alias); - updatesToSkip.Add(update); - return; - } + var editorValue = _jsonSerializer.Serialize(toEditorValue); + var dbValue = valueEditor.FromEditor(new ContentPropertyData(editorValue, null), null); + if (dbValue is not string stringValue || stringValue.DetectIsJson() is false) + { + _logger.LogError( + " - value editor did not yield a valid JSON string as FromEditor value property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})", + propertyDataDto.Id, + propertyType.Name, + propertyType.Id, + propertyType.Alias); + updatesToSkip.Add(update); + return; + } + + stringValue = UpdateDatabaseValue(stringValue); - stringValue = UpdateDatabaseValue(stringValue); + propertyDataDto.TextValue = stringValue; + }, token); + } - propertyDataDto.TextValue = stringValue; - }); - ExecutionContext.RestoreFlow(); + await task; + }).GetAwaiter().GetResult(); updateBatch.RemoveAll(updatesToSkip.Contains); @@ -248,7 +283,8 @@ private bool Handle(IPropertyType[] propertyTypes, IDictionary l 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."); + throw new InvalidOperationException( + $"The database batch update was supposed to update {updateBatch.Count} property DTO entries, but it updated {result} entries."); } _logger.LogDebug( diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockGridEditorProperties.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockGridEditorProperties.cs index cc80a77ea9c0..64fc64a961b6 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockGridEditorProperties.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockGridEditorProperties.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -19,8 +20,9 @@ public ConvertBlockGridEditorProperties( IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, ILanguageService languageService, - IOptions options) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + IOptions options, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) => SkipMigration = options.Value.SkipBlockGridEditors; protected override IEnumerable PropertyEditorAliases @@ -40,8 +42,9 @@ public ConvertBlockGridEditorProperties( IDataTypeService dataTypeService, IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, - ILanguageService languageService) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + ILanguageService languageService, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) { } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockListEditorProperties.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockListEditorProperties.cs index e920a3b6d852..5ea86f07f3d0 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockListEditorProperties.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertBlockListEditorProperties.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Web; @@ -19,8 +20,9 @@ public ConvertBlockListEditorProperties( IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, ILanguageService languageService, - IOptions options) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + IOptions options, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) => SkipMigration = options.Value.SkipBlockListEditors; protected override IEnumerable PropertyEditorAliases @@ -40,8 +42,9 @@ public ConvertBlockListEditorProperties( IDataTypeService dataTypeService, IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, - ILanguageService languageService) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + ILanguageService languageService, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) { } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertRichTextEditorProperties.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertRichTextEditorProperties.cs index 6023917b4cd1..60c143805956 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertRichTextEditorProperties.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertRichTextEditorProperties.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; 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; @@ -21,8 +22,9 @@ public ConvertRichTextEditorProperties( IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, ILanguageService languageService, - IOptions options) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + IOptions options, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) => SkipMigration = options.Value.SkipRichTextEditors; protected override IEnumerable PropertyEditorAliases @@ -60,8 +62,9 @@ public ConvertRichTextEditorProperties( IDataTypeService dataTypeService, IJsonSerializer jsonSerializer, IUmbracoContextFactory umbracoContextFactory, - ILanguageService languageService) - : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService) + ILanguageService languageService, + ICoreScopeProvider coreScopeProvider) + : base(context, logger, contentTypeService, dataTypeService, jsonSerializer, umbracoContextFactory, languageService, coreScopeProvider) { } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs index b4521d3bbd5e..27f9d9b40ac8 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs @@ -3,14 +3,13 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Core.PropertyEditors; -internal sealed class BlockValuePropertyIndexValueFactory : - NestedPropertyIndexValueFactoryBase, +internal class BlockValuePropertyIndexValueFactory : + BlockValuePropertyIndexValueFactoryBase, IBlockValuePropertyIndexValueFactory { public BlockValuePropertyIndexValueFactory( @@ -21,19 +20,14 @@ public BlockValuePropertyIndexValueFactory( { } - protected override IContentType? GetContentTypeOfNestedItem(BlockItemData input, IDictionary contentTypeDictionary) - => contentTypeDictionary.TryGetValue(input.ContentTypeKey, out var result) ? result : null; - - protected override IDictionary GetRawProperty(BlockItemData blockItemData) - => blockItemData.Values - .Where(p => p.Culture is null && p.Segment is null) - .ToDictionary(p => p.Alias, p => p.Value); - - protected override IEnumerable GetDataItems(IndexValueFactoryBlockValue input) => input.ContentData; + protected override IEnumerable GetDataItems(IndexValueFactoryBlockValue input, bool published) + => GetDataItems(input.ContentData, input.Expose, published); // we only care about the content data when extracting values for indexing - not the layouts nor the settings internal class IndexValueFactoryBlockValue { public List ContentData { get; set; } = new(); + + public List Expose { get; set; } = new(); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactoryBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactoryBase.cs new file mode 100644 index 000000000000..f17e9997ac52 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactoryBase.cs @@ -0,0 +1,307 @@ +using System.Text; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal abstract class BlockValuePropertyIndexValueFactoryBase : JsonPropertyIndexValueFactoryBase +{ + private readonly PropertyEditorCollection _propertyEditorCollection; + + protected BlockValuePropertyIndexValueFactoryBase( + PropertyEditorCollection propertyEditorCollection, + IJsonSerializer jsonSerializer, + IOptionsMonitor indexingSettings) + : base(jsonSerializer, indexingSettings) + { + _propertyEditorCollection = propertyEditorCollection; + } + + protected override IEnumerable Handle( + TSerialized deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures, + IDictionary contentTypeDictionary) + { + var result = new List(); + + var index = 0; + foreach (RawDataItem rawData in GetDataItems(deserializedPropertyValue, published)) + { + if (contentTypeDictionary.TryGetValue(rawData.ContentTypeKey, out IContentType? contentType) is false) + { + continue; + } + + var propertyTypeDictionary = + contentType + .CompositionPropertyTypes + .Select(propertyType => + { + // We want to ensure that the nested properties are set vary by culture if the parent is + // This is because it's perfectly valid to have a nested property type that's set to invariant even if the parent varies. + // For instance in a block list, the list it self can vary, but the elements can be invariant, at the same time. + if (culture is not null) + { + propertyType.Variations |= ContentVariation.Culture; + } + + if (segment is not null) + { + propertyType.Variations |= ContentVariation.Segment; + } + + return propertyType; + }) + .ToDictionary(x => x.Alias); + + result.AddRange(GetNestedResults( + $"{property.Alias}.items[{index}]", + culture, + segment, + published, + propertyTypeDictionary, + rawData, + availableCultures, + contentTypeDictionary)); + + index++; + } + + return RenameKeysToEnsureRawSegmentsIsAPrefix(result); + } + + /// + /// Rename keys that count the RAW-constant, to ensure the RAW-constant is a prefix. + /// + private IEnumerable RenameKeysToEnsureRawSegmentsIsAPrefix( + List indexContent) + { + foreach (IndexValue indexValue in indexContent) + { + // Tests if key includes the RawFieldPrefix and it is not in the start + if (indexValue.FieldName.Substring(1).Contains(UmbracoExamineFieldNames.RawFieldPrefix)) + { + indexValue.FieldName = UmbracoExamineFieldNames.RawFieldPrefix + + indexValue.FieldName.Replace(UmbracoExamineFieldNames.RawFieldPrefix, string.Empty); + } + } + + return indexContent; + } + + /// + /// Get the data items of a parent item. E.g. block list have contentData. + /// + protected abstract IEnumerable GetDataItems(TSerialized input, bool published); + + /// + /// Unwraps block item data as data items. + /// + protected IEnumerable GetDataItems(IList contentData, IList expose, bool published) + { + if (published is false) + { + return contentData.Select(ToRawData); + } + + var indexData = new List(); + foreach (BlockItemData blockItemData in contentData) + { + var exposedCultures = expose + .Where(e => e.ContentKey == blockItemData.Key) + .Select(e => e.Culture) + .ToArray(); + + if (exposedCultures.Any() is false) + { + continue; + } + + if (exposedCultures.Contains(null) + || exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture))) + { + indexData.Add(ToRawData(blockItemData)); + continue; + } + + indexData.Add( + ToRawData( + blockItemData.ContentTypeKey, + blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture)))); + } + + return indexData; + } + + /// + /// Index a key with the name of the property, using the relevant content of all the children. + /// + protected override IEnumerable HandleResume( + List indexedContent, + IProperty property, + string? culture, + string? segment, + bool published) + { + var indexedCultures = indexedContent + .DistinctBy(v => v.Culture) + .Select(v => v.Culture) + .WhereNotNull() + .ToArray(); + var cultures = indexedCultures.Any() + ? indexedCultures + : new string?[] { culture }; + + return cultures.Select(c => new IndexValue + { + Culture = c, FieldName = property.Alias, Values = [GetResumeFromAllContent(indexedContent, c)] + }); + } + + /// + /// Gets a resume as string of all the content in this nested type. + /// + /// All the indexed content for this property. + /// The culture to get the resume for. + /// the string with all relevant content from + private static string GetResumeFromAllContent(List indexedContent, string? culture) + { + var stringBuilder = new StringBuilder(); + foreach (IndexValue indexValue in indexedContent.Where(v => v.Culture == culture || v.Culture is null)) + { + // Ignore Raw fields + if (indexValue.FieldName.Contains(UmbracoExamineFieldNames.RawFieldPrefix)) + { + continue; + } + + foreach (var value in indexValue.Values) + { + if (value is not null) + { + stringBuilder.AppendLine(value.ToString()); + } + } + } + + return stringBuilder.ToString(); + } + + /// + /// Gets the content to index for the nested type. E.g. Block list, Nested Content, etc.. + /// + private IEnumerable GetNestedResults( + string keyPrefix, + string? culture, + string? segment, + bool published, + IDictionary propertyTypeDictionary, + RawDataItem rawData, + IEnumerable availableCultures, + IDictionary contentTypeDictionary) + { + foreach (RawPropertyData rawPropertyData in rawData.Properties) + { + if (propertyTypeDictionary.TryGetValue(rawPropertyData.Alias, out IPropertyType? propertyType)) + { + IDataEditor? editor = _propertyEditorCollection[propertyType.PropertyEditorAlias]; + if (editor is null) + { + continue; + } + + IProperty subProperty = new Property(propertyType); + IEnumerable indexValues = null!; + + var propertyCulture = rawPropertyData.Culture ?? culture; + + if (propertyType.VariesByCulture() && propertyCulture is null) + { + foreach (var availableCulture in availableCultures) + { + subProperty.SetValue(rawPropertyData.Value, availableCulture, segment); + if (published) + { + subProperty.PublishValues(availableCulture, segment ?? "*"); + } + indexValues = + editor.PropertyIndexValueFactory.GetIndexValues(subProperty, availableCulture, segment, published, availableCultures, contentTypeDictionary); + } + } + else + { + subProperty.SetValue(rawPropertyData.Value, propertyCulture, segment); + if (published) + { + subProperty.PublishValues(propertyCulture ?? "*", segment ?? "*"); + } + indexValues = editor.PropertyIndexValueFactory.GetIndexValues(subProperty, propertyCulture, segment, published, availableCultures, contentTypeDictionary); + } + + var rawDataCultures = rawData.Properties.Select(property => property.Culture).Distinct().WhereNotNull().ToArray(); + foreach (IndexValue indexValue in indexValues) + { + indexValue.FieldName = $"{keyPrefix}.{indexValue.FieldName}"; + + if (indexValue.Culture is null && rawDataCultures.Any()) + { + foreach (var rawDataCulture in rawDataCultures) + { + yield return new IndexValue + { + Culture = rawDataCulture, + FieldName = indexValue.FieldName, + Values = indexValue.Values + }; + } + } + else + { + indexValue.Culture = rawDataCultures.Any() ? indexValue.Culture : null; + yield return indexValue; + } + } + } + } + } + + private RawDataItem ToRawData(BlockItemData blockItemData) + => ToRawData(blockItemData.ContentTypeKey, blockItemData.Values); + + private RawDataItem ToRawData(Guid contentTypeKey, IEnumerable values) + => new() + { + ContentTypeKey = contentTypeKey, + Properties = values.Select(value => new RawPropertyData + { + Alias = value.Alias, + Culture = value.Culture, + Value = value.Value + }) + }; + + protected class RawDataItem + { + public required Guid ContentTypeKey { get; init; } + + public required IEnumerable Properties { get; init; } + } + + protected class RawPropertyData + { + public required string Alias { get; init; } + + public required object? Value { get; init; } + + public required string? Culture { get; init; } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs deleted file mode 100644 index 40e691b8d79d..000000000000 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.Text; -using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Infrastructure.Examine; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Core.PropertyEditors; - -internal abstract class NestedPropertyIndexValueFactoryBase : JsonPropertyIndexValueFactoryBase -{ - private readonly PropertyEditorCollection _propertyEditorCollection; - - protected NestedPropertyIndexValueFactoryBase( - PropertyEditorCollection propertyEditorCollection, - IJsonSerializer jsonSerializer, - IOptionsMonitor indexingSettings) - : base(jsonSerializer, indexingSettings) - { - _propertyEditorCollection = propertyEditorCollection; - } - - protected override IEnumerable>> Handle( - TSerialized deserializedPropertyValue, - IProperty property, - string? culture, - string? segment, - bool published, - IEnumerable availableCultures, - IDictionary contentTypeDictionary) - { - var result = new List>>(); - - var index = 0; - foreach (TItem nestedContentRowValue in GetDataItems(deserializedPropertyValue)) - { - IContentType? contentType = GetContentTypeOfNestedItem(nestedContentRowValue, contentTypeDictionary); - - if (contentType is null) - { - continue; - } - - var propertyTypeDictionary = - contentType - .CompositionPropertyGroups - .SelectMany(x => x.PropertyTypes!) - .Select(propertyType => - { - // We want to ensure that the nested properties are set vary by culture if the parent is - // This is because it's perfectly valid to have a nested property type that's set to invariant even if the parent varies. - // For instance in a block list, the list it self can vary, but the elements can be invariant, at the same time. - if (culture is not null) - { - propertyType.Variations |= ContentVariation.Culture; - } - - if (segment is not null) - { - propertyType.Variations |= ContentVariation.Segment; - } - - return propertyType; - }) - .ToDictionary(x => x.Alias); - - result.AddRange(GetNestedResults( - $"{property.Alias}.items[{index}]", - culture, - segment, - published, - propertyTypeDictionary, - nestedContentRowValue, - availableCultures, - contentTypeDictionary)); - - index++; - } - - return RenameKeysToEnsureRawSegmentsIsAPrefix(result); - } - - /// - /// Rename keys that count the RAW-constant, to ensure the RAW-constant is a prefix. - /// - private IEnumerable>> RenameKeysToEnsureRawSegmentsIsAPrefix( - List>> indexContent) - { - foreach (KeyValuePair> indexedKeyValuePair in indexContent) - { - // Tests if key includes the RawFieldPrefix and it is not in the start - if (indexedKeyValuePair.Key.Substring(1).Contains(UmbracoExamineFieldNames.RawFieldPrefix)) - { - var newKey = UmbracoExamineFieldNames.RawFieldPrefix + - indexedKeyValuePair.Key.Replace(UmbracoExamineFieldNames.RawFieldPrefix, string.Empty); - yield return new KeyValuePair>(newKey, indexedKeyValuePair.Value); - } - else - { - yield return indexedKeyValuePair; - } - } - } - - /// - /// Gets the content type using the nested item. - /// - protected abstract IContentType? GetContentTypeOfNestedItem(TItem nestedItem, IDictionary contentTypeDictionary); - - /// - /// Gets the raw data from a nested item. - /// - protected abstract IDictionary GetRawProperty(TItem nestedItem); - - /// - /// Get the data times of a parent item. E.g. block list have contentData. - /// - protected abstract IEnumerable GetDataItems(TSerialized input); - - /// - /// Index a key with the name of the property, using the relevant content of all the children. - /// - protected override IEnumerable>> HandleResume( - List>> indexedContent, - IProperty property, - string? culture, - string? segment, - bool published) - { - yield return new KeyValuePair>( - property.Alias, - GetResumeFromAllContent(indexedContent).Yield()); - } - - /// - /// Gets a resume as string of all the content in this nested type. - /// - /// All the indexed content for this property. - /// the string with all relevant content from - private static string GetResumeFromAllContent(List>> indexedContent) - { - var stringBuilder = new StringBuilder(); - foreach ((var indexKey, IEnumerable? indexedValue) in indexedContent) - { - // Ignore Raw fields - if (indexKey.Contains(UmbracoExamineFieldNames.RawFieldPrefix)) - { - continue; - } - - foreach (var value in indexedValue) - { - if (value is not null) - { - stringBuilder.AppendLine(value.ToString()); - } - } - } - - return stringBuilder.ToString(); - } - - /// - /// Gets the content to index for the nested type. E.g. Block list, Nested Content, etc.. - /// - private IEnumerable>> GetNestedResults( - string keyPrefix, - string? culture, - string? segment, - bool published, - IDictionary propertyTypeDictionary, - TItem nestedContentRowValue, - IEnumerable availableCultures, - IDictionary contentTypeDictionary) - { - foreach ((var propertyAlias, var propertyValue) in GetRawProperty(nestedContentRowValue)) - { - if (propertyTypeDictionary.TryGetValue(propertyAlias, out IPropertyType? propertyType)) - { - IDataEditor? editor = _propertyEditorCollection[propertyType.PropertyEditorAlias]; - if (editor is null) - { - continue; - } - - IProperty subProperty = new Property(propertyType); - IEnumerable>> indexValues = null!; - - if (propertyType.VariesByCulture() && culture is null) - { - foreach (var availableCulture in availableCultures) - { - subProperty.SetValue(propertyValue, availableCulture, segment); - if (published) - { - subProperty.PublishValues(availableCulture, segment ?? "*"); - } - indexValues = - editor.PropertyIndexValueFactory.GetIndexValues(subProperty, availableCulture, segment, published, availableCultures, contentTypeDictionary); - } - } - else - { - subProperty.SetValue(propertyValue, culture, segment); - if (published) - { - subProperty.PublishValues(culture ?? "*", segment ?? "*"); - } - indexValues = editor.PropertyIndexValueFactory.GetIndexValues(subProperty, culture, segment, published, availableCultures, contentTypeDictionary); - } - - foreach ((var nestedAlias, IEnumerable nestedValue) in indexValues) - { - yield return new KeyValuePair>( - $"{keyPrefix}.{nestedAlias}", nestedValue!); - } - } - } - } -} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs index e2236e2976ec..0eb1ee257ad1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs @@ -2,15 +2,13 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; -internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFactoryBase, IRichTextPropertyIndexValueFactory +internal class RichTextPropertyIndexValueFactory : BlockValuePropertyIndexValueFactoryBase, IRichTextPropertyIndexValueFactory { private readonly IJsonSerializer _jsonSerializer; private readonly ILogger _logger; @@ -26,18 +24,7 @@ public RichTextPropertyIndexValueFactory( _logger = logger; } - [Obsolete("Use constructor that doesn't take IContentTypeService, scheduled for removal in V15")] - public RichTextPropertyIndexValueFactory( - PropertyEditorCollection propertyEditorCollection, - IJsonSerializer jsonSerializer, - IOptionsMonitor indexingSettings, - IContentTypeService contentTypeService, - ILogger logger) - : this(propertyEditorCollection, jsonSerializer, indexingSettings, logger) - { - } - - public new IEnumerable>> GetIndexValues( + public override IEnumerable GetIndexValues( IProperty property, string? culture, string? segment, @@ -48,33 +35,101 @@ public RichTextPropertyIndexValueFactory( var val = property.GetValue(culture, segment, published); if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(val, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue) is false) { - yield break; + return []; + } + + // always index the "raw" value + var indexValues = new List + { + new IndexValue + { + Culture = culture, + FieldName = $"{UmbracoExamineFieldNames.RawFieldPrefix}{property.Alias}", + Values = [richTextEditorValue.Markup] + } + }; + + // the actual content (RTE content without markup, i.e. the actual words) must be indexed under the property alias + var richTextWithoutMarkup = richTextEditorValue.Markup.StripHtml(); + if (richTextEditorValue.Blocks?.ContentData.Any() is not true) + { + // no blocks; index the content for the culture and be done with it + indexValues.Add(new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = [richTextWithoutMarkup] + }); + return indexValues; } // the "blocks values resume" (the combined searchable text values from all blocks) is stored as a string value under the property alias by the base implementation - var blocksIndexValues = base.GetIndexValues(property, culture, segment, published, availableCultures, contentTypeDictionary).ToDictionary(pair => pair.Key, pair => pair.Value); - var blocksIndexValuesResume = blocksIndexValues.TryGetValue(property.Alias, out IEnumerable? blocksIndexValuesResumeValue) - ? blocksIndexValuesResumeValue.FirstOrDefault() as string - : null; + var blocksIndexValuesResumes = base + .GetIndexValues(property, culture, segment, published, availableCultures, contentTypeDictionary) + .Where(value => value.FieldName == property.Alias) + .GroupBy(value => value.Culture?.ToLowerInvariant()) + .Select(group => new + { + Culture = group.Key, + Resume = string.Join(Environment.NewLine, group.Select(v => v.Values.FirstOrDefault() as string)) + }) + .ToArray(); - // index the stripped HTML values combined with "blocks values resume" value - yield return new KeyValuePair>( - property.Alias, - new object[] { $"{richTextEditorValue.Markup.StripHtml()} {blocksIndexValuesResume}" }); + // is this RTE sat on culture variant content? + if (culture is not null) + { + // yes, append the "block values resume" for the specific culture only (if present) + var blocksResume = blocksIndexValuesResumes + .FirstOrDefault(r => r.Culture.InvariantEquals(culture))? + .Resume; - // store the raw value - yield return new KeyValuePair>( - $"{UmbracoExamineFieldNames.RawFieldPrefix}{property.Alias}", new object[] { richTextEditorValue.Markup }); - } + indexValues.Add(new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = [$"{richTextWithoutMarkup}{Environment.NewLine}{blocksResume}"] + }); + return indexValues; + } - protected override IContentType? GetContentTypeOfNestedItem(BlockItemData nestedItem, IDictionary contentTypeDictionary) - => contentTypeDictionary.TryGetValue(nestedItem.ContentTypeKey, out var result) ? result : null; + // is there an invariant "block values resume"? this might happen for purely invariant blocks or in a culture invariant context + var invariantResume = blocksIndexValuesResumes + .FirstOrDefault(r => r.Culture is null) + ?.Resume; + if (invariantResume != null) + { + // yes, append the invariant "block values resume" + indexValues.Add(new IndexValue + { + Culture = culture, + FieldName = property.Alias, + Values = [$"{richTextWithoutMarkup}{Environment.NewLine}{invariantResume}"] + }); + return indexValues; + } + + // at this point we have encountered block level variance - add explicit index values for all "block values resume" cultures found + indexValues.AddRange(blocksIndexValuesResumes.Select(resume => + new IndexValue + { + Culture = resume.Culture, + FieldName = property.Alias, + Values = [$"{richTextWithoutMarkup}{Environment.NewLine}{resume.Resume}"] + })); - protected override IDictionary GetRawProperty(BlockItemData blockItemData) - => blockItemData.Values - .Where(p => p.Culture is null && p.Segment is null) - .ToDictionary(p => p.Alias, p => p.Value); + // if one or more cultures did not have any (exposed) blocks, ensure that the RTE content is still indexed for those cultures + IEnumerable missingBlocksResumeCultures = availableCultures.Except(blocksIndexValuesResumes.Select(r => r.Culture), StringComparer.CurrentCultureIgnoreCase); + indexValues.AddRange(missingBlocksResumeCultures.Select(missingResumeCulture => + new IndexValue + { + Culture = missingResumeCulture, + FieldName = property.Alias, + Values = [richTextWithoutMarkup] + })); + + return indexValues; + } - protected override IEnumerable GetDataItems(RichTextEditorValue input) - => input.Blocks?.ContentData ?? new List(); + protected override IEnumerable GetDataItems(RichTextEditorValue input, bool published) + => GetDataItems(input.Blocks?.ContentData ?? [], input.Blocks?.Expose ?? [], published); } diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 2e4604904238..d28829dd4d0f 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -199,6 +199,10 @@ private async Task StartAsync(CancellationToken cancellationToken, bool isRestar break; } + // + var postRuntimePremigrationsUpgradeNotification = new PostRuntimePremigrationsUpgradeNotification(); + await _eventAggregator.PublishAsync(postRuntimePremigrationsUpgradeNotification, cancellationToken); + // If level is Run and reason is UpgradeMigrations, that means we need to perform an unattended upgrade var unattendedUpgradeNotification = new RuntimeUnattendedUpgradeNotification(); await _eventAggregator.PublishAsync(unattendedUpgradeNotification, cancellationToken); diff --git a/src/Umbraco.Infrastructure/Scoping/AmbientScopeContextStack.cs b/src/Umbraco.Infrastructure/Scoping/AmbientScopeContextStack.cs index 18e68120a625..1d2a947ff751 100644 --- a/src/Umbraco.Infrastructure/Scoping/AmbientScopeContextStack.cs +++ b/src/Umbraco.Infrastructure/Scoping/AmbientScopeContextStack.cs @@ -5,35 +5,46 @@ namespace Umbraco.Cms.Infrastructure.Scoping; internal class AmbientScopeContextStack : IAmbientScopeContextStack { + private static Lock _lock = new(); private static AsyncLocal> _stack = new(); public IScopeContext? AmbientContext { get { - if (_stack.Value?.TryPeek(out IScopeContext? ambientContext) ?? false) + lock (_lock) { - return ambientContext; + if (_stack.Value?.TryPeek(out IScopeContext? ambientContext) ?? false) + { + return ambientContext; + } + + return null; } - return null; } } public IScopeContext Pop() { - if (_stack.Value?.TryPop(out IScopeContext? ambientContext) ?? false) + lock (_lock) { - return ambientContext; - } + if (_stack.Value?.TryPop(out IScopeContext? ambientContext) ?? false) + { + return ambientContext; + } - throw new InvalidOperationException("No AmbientContext was found."); + throw new InvalidOperationException("No AmbientContext was found."); + } } public void Push(IScopeContext scope) { - _stack.Value ??= new ConcurrentStack(); + lock (_lock) + { + _stack.Value ??= new ConcurrentStack(); - _stack.Value.Push(scope); + _stack.Value.Push(scope); + } } } diff --git a/src/Umbraco.Infrastructure/Scoping/AmbientScopeStack.cs b/src/Umbraco.Infrastructure/Scoping/AmbientScopeStack.cs index 3ad5e89e5113..efb5ea27acae 100644 --- a/src/Umbraco.Infrastructure/Scoping/AmbientScopeStack.cs +++ b/src/Umbraco.Infrastructure/Scoping/AmbientScopeStack.cs @@ -4,36 +4,46 @@ namespace Umbraco.Cms.Infrastructure.Scoping { internal class AmbientScopeStack : IAmbientScopeStack { + private static Lock _lock = new(); private static AsyncLocal> _stack = new (); public IScope? AmbientScope { get { - if (_stack.Value?.TryPeek(out IScope? ambientScope) ?? false) + lock (_lock) { - return ambientScope; - } + if (_stack.Value?.TryPeek(out IScope? ambientScope) ?? false) + { + return ambientScope; + } - return null; + return null; + } } } public IScope Pop() { - if (_stack.Value?.TryPop(out IScope? ambientScope) ?? false) + lock (_lock) { - return ambientScope; - } - throw new InvalidOperationException("No AmbientScope was found."); + + if (_stack.Value?.TryPop(out IScope? ambientScope) ?? false) + { + return ambientScope; + } + + throw new InvalidOperationException("No AmbientScope was found."); + } } public void Push(IScope scope) { - _stack.Value ??= new ConcurrentStack(); - - _stack.Value.Push(scope); + lock (_lock) + { + (_stack.Value ??= new ConcurrentStack()).Push(scope); + } } } } diff --git a/src/Umbraco.Infrastructure/Scoping/Scope.cs b/src/Umbraco.Infrastructure/Scoping/Scope.cs index 3c1495f9c1ef..ca7cd28ef246 100644 --- a/src/Umbraco.Infrastructure/Scoping/Scope.cs +++ b/src/Umbraco.Infrastructure/Scoping/Scope.cs @@ -367,7 +367,7 @@ public override void Dispose() $"The {nameof(Scope)} {InstanceId} being disposed is not the Ambient {nameof(Scope)} {_scopeProvider.AmbientScope?.InstanceId.ToString() ?? "NULL"}. This typically indicates that a child {nameof(Scope)} was not disposed, or flowed to a child thread that was not awaited, or concurrent threads are accessing the same {nameof(Scope)} (Ambient context) which is not supported. If using Task.Run (or similar) as a fire and forget tasks or to run threads in parallel you must suppress execution context flow with ExecutionContext.SuppressFlow() and ExecutionContext.RestoreFlow()."; #if DEBUG_SCOPES - Scope ambient = _scopeProvider.AmbientScope; + Scope? ambient = _scopeProvider.AmbientScope; _logger.LogWarning("Dispose error (" + (ambient == null ? "no" : "other") + " ambient)"); if (ambient == null) { @@ -377,8 +377,8 @@ public override void Dispose() ScopeInfo ambientInfos = _scopeProvider.GetScopeInfo(ambient); ScopeInfo disposeInfos = _scopeProvider.GetScopeInfo(this); throw new InvalidOperationException($"{failedMessage} (see ctor stack traces).\r\n" - + "- ambient ->\r\n" + ambientInfos.ToString() + "\r\n" - + "- dispose ->\r\n" + disposeInfos.ToString() + "\r\n"); + + "- ambient ->\r\n" + ambientInfos + "\r\n" + + "- dispose ->\r\n" + disposeInfos + "\r\n"); #else throw new InvalidOperationException(failedMessage); #endif diff --git a/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs b/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs index 1abdaa28af79..9454443ecdbd 100644 --- a/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs +++ b/src/Umbraco.Infrastructure/Scoping/ScopeProvider.cs @@ -264,7 +264,7 @@ public ScopeInfo GetScopeInfo(IScope scope) { lock (s_staticScopeInfosLock) { - return s_staticScopeInfos.TryGetValue(scope, out ScopeInfo scopeInfo) ? scopeInfo : null; + return s_staticScopeInfos.TryGetValue(scope, out ScopeInfo? scopeInfo) ? scopeInfo : null!; } } @@ -299,7 +299,7 @@ public void RegisterContext(IScope scope, string context) lock (s_staticScopeInfosLock) { - if (s_staticScopeInfos.TryGetValue(scope, out ScopeInfo info) == false) + if (s_staticScopeInfos.TryGetValue(scope, out ScopeInfo? info) == false) { info = null; } @@ -324,7 +324,7 @@ public void RegisterContext(IScope scope, string context) sb.Append(s.InstanceId.ToString("N").Substring(0, 8)); var ss = s as Scope; - s = ss?.ParentScope; + s = ss?.ParentScope!; } _logger.LogTrace("Register " + (context ?? "null") + " context " + sb); @@ -336,7 +336,7 @@ public void RegisterContext(IScope scope, string context) _logger.LogTrace("At:\r\n" + Head(Environment.StackTrace, 16)); - info.Context = context; + info.Context = context!; } } @@ -431,15 +431,15 @@ public ScopeInfo(IScope scope, string ctorStack) public IScope Scope { get; } // the scope itself // the scope's parent identifier - public Guid Parent => ((Scope)Scope).ParentScope == null ? Guid.Empty : ((Scope)Scope).ParentScope.InstanceId; + public Guid Parent => ((Scope)Scope).ParentScope == null ? Guid.Empty : ((Scope)Scope).ParentScope!.InstanceId; public DateTime Created { get; } // the date time the scope was created public bool Disposed { get; set; } // whether the scope has been disposed already - public string Context { get; set; } // the current 'context' that contains the scope (null, "http" or "lcc") + public string Context { get; set; }= string.Empty; // the current 'context' that contains the scope (null, "http" or "lcc") public string CtorStack { get; } // the stacktrace of the scope ctor //public string DisposedStack { get; set; } // the stacktrace when disposed - public string NullStack { get; set; } // the stacktrace when the 'context' that contains the scope went null + public string NullStack { get; set; } = string.Empty; // the stacktrace when the 'context' that contains the scope went null public override string ToString() => new StringBuilder() .AppendLine("ScopeInfo:") diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs index 72d966830650..196dd3950e05 100644 --- a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs @@ -32,9 +32,7 @@ public async Task HandleAsync(UmbracoApplicationStartedNotification notification return; } - await Task.WhenAll( - _documentCacheService.SeedAsync(cancellationToken), - _mediaCacheService.SeedAsync(cancellationToken) - ); + await _documentCacheService.SeedAsync(cancellationToken); + await _mediaCacheService.SeedAsync(cancellationToken); } } diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs index 2586744ff11a..757ea5cae189 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs @@ -137,7 +137,7 @@ public override int Level { get { - INavigationQueryService? navigationQueryService = null; + INavigationQueryService? navigationQueryService; switch (_contentNode.ContentType.ItemType) { case PublishedItemType.Content: @@ -150,8 +150,13 @@ public override int Level throw new NotImplementedException("Level is not implemented for " + _contentNode.ContentType.ItemType); } - navigationQueryService.TryGetLevel(Key, out int level); - return level; + // Attempt to retrieve the level, returning 0 if it fails or if level is null. + if (navigationQueryService.TryGetLevel(Key, out var level) && level.HasValue) + { + return level.Value; + } + + return 0; } } diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index b73f48dcd7ad..011f0cd24b17 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -72,15 +72,18 @@ public DocumentCacheService( public async Task GetByKeyAsync(Guid key, bool? preview = null) { - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - bool calculatedPreview = preview ?? GetPreview(); ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( GetCacheKey(key, calculatedPreview), // Unique key to the cache entry - async cancel => await _databaseCacheRepository.GetContentSourceAsync(key, calculatedPreview)); + async cancel => + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? contentCacheNode = await _databaseCacheRepository.GetContentSourceAsync(key, calculatedPreview); + scope.Complete(); + return contentCacheNode; + }); - scope.Complete(); return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, calculatedPreview).CreateModel(_publishedModelFactory); } @@ -99,11 +102,16 @@ private bool GetPreview() bool calculatedPreview = preview ?? GetPreview(); - using ICoreScope scope = _scopeProvider.CreateCoreScope(); ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( GetCacheKey(keyAttempt.Result, calculatedPreview), // Unique key to the cache entry - async cancel => await _databaseCacheRepository.GetContentSourceAsync(id, calculatedPreview)); - scope.Complete(); + async cancel => + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? contentCacheNode = await _databaseCacheRepository.GetContentSourceAsync(id, calculatedPreview); + scope.Complete(); + return contentCacheNode; + }); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, calculatedPreview).CreateModel(_publishedModelFactory);; } @@ -120,8 +128,6 @@ public IEnumerable GetByContentType(IPublishedContentType con public async Task SeedAsync(CancellationToken cancellationToken) { - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - foreach (Guid key in SeedKeys) { if(cancellationToken.IsCancellationRequested) @@ -131,31 +137,32 @@ public async Task SeedAsync(CancellationToken cancellationToken) var cacheKey = GetCacheKey(key, false); - // We'll use GetOrCreateAsync because it may be in the second level cache, in which case we don't have to re-seed. - ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync( - cacheKey, - async cancel => - { - ContentCacheNode? cacheNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); - - // We don't want to seed drafts - if (cacheNode is null || cacheNode.IsDraft) + // We'll use GetOrCreateAsync because it may be in the second level cache, in which case we don't have to re-seed. + ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync( + cacheKey, + async cancel => { - return null; - } + using ICoreScope scope = _scopeProvider.CreateCoreScope(); - return cacheNode; - }, - GetSeedEntryOptions()); + ContentCacheNode? cacheNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); + + scope.Complete(); + // We don't want to seed drafts + if (cacheNode is null || cacheNode.IsDraft) + { + return null; + } + + return cacheNode; + }, + GetSeedEntryOptions()); - // If the value is null, it's likely because - if (cachedValue is null) + // If the value is null, it's likely because + if (cachedValue is null) { await _hybridCache.RemoveAsync(cacheKey); } } - - scope.Complete(); } private HybridCacheEntryOptions GetSeedEntryOptions() => new() @@ -257,6 +264,7 @@ public void Rebuild(IReadOnlyCollection contentTypeIds) using ICoreScope scope = _scopeProvider.CreateCoreScope(); _databaseCacheRepository.Rebuild(contentTypeIds.ToList()); IEnumerable contentByContentTypeKey = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeIds.Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.DocumentType).Result), ContentCacheDataSerializerEntityType.Document); + scope.Complete(); foreach (ContentCacheNode content in contentByContentTypeKey) { @@ -268,6 +276,6 @@ public void Rebuild(IReadOnlyCollection contentTypeIds) } } - scope.Complete(); + } } diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 1bd2fe3e2b54..98ba341c1950 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -75,13 +75,16 @@ public MediaCacheService( return null; } - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( $"{key}", // Unique key to the cache entry - async cancel => await _databaseCacheRepository.GetMediaSourceAsync(idAttempt.Result)); + async cancel => + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? mediaCacheNode = await _databaseCacheRepository.GetMediaSourceAsync(idAttempt.Result); + scope.Complete(); + return mediaCacheNode; + }); - scope.Complete(); return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); } @@ -93,11 +96,16 @@ public MediaCacheService( return null; } - using ICoreScope scope = _scopeProvider.CreateCoreScope(); ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( $"{keyAttempt.Result}", // Unique key to the cache entry - async cancel => await _databaseCacheRepository.GetMediaSourceAsync(id)); - scope.Complete(); + async cancel => + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? mediaCacheNode = await _databaseCacheRepository.GetMediaSourceAsync(id); + scope.Complete(); + return mediaCacheNode; + }); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); } @@ -144,7 +152,6 @@ public async Task DeleteItemAsync(IContentBase media) public async Task SeedAsync(CancellationToken cancellationToken) { - using ICoreScope scope = _scopeProvider.CreateCoreScope(); foreach (Guid key in SeedKeys) { @@ -157,7 +164,13 @@ public async Task SeedAsync(CancellationToken cancellationToken) ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync( cacheKey, - async cancel => await _databaseCacheRepository.GetMediaSourceAsync(key), + async cancel => + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? mediaCacheNode = await _databaseCacheRepository.GetMediaSourceAsync(key); + scope.Complete(); + return mediaCacheNode; + }, GetSeedEntryOptions()); if (cachedValue is null) @@ -165,8 +178,6 @@ public async Task SeedAsync(CancellationToken cancellationToken) await _hybridCache.RemoveAsync(cacheKey); } } - - scope.Complete(); } public void Rebuild(IReadOnlyCollection contentTypeIds) diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 4ccaccfba541..22861429794e 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -194,8 +194,8 @@ public static IUmbracoBuilder AddRecurringBackgroundJobs(this IUmbracoBuilder bu builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); return builder; } diff --git a/src/Umbraco.Web.UI.Client b/src/Umbraco.Web.UI.Client index 49803f8f144d..29583d3d34f5 160000 --- a/src/Umbraco.Web.UI.Client +++ b/src/Umbraco.Web.UI.Client @@ -1 +1 @@ -Subproject commit 49803f8f144dfde7fcaa93eee5a1eca37d62d3af +Subproject commit 29583d3d34f57e98052450128435fcb06a0c1984 diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index c22b5ae7392e..d6e9a44df006 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -3,6 +3,7 @@ Umbraco.Cms.Web.UI false false + false diff --git a/templates/UmbracoProject/UmbracoProject.csproj b/templates/UmbracoProject/UmbracoProject.csproj index 019d5d2990cb..d4b4b548fda0 100644 --- a/templates/UmbracoProject/UmbracoProject.csproj +++ b/templates/UmbracoProject/UmbracoProject.csproj @@ -4,6 +4,7 @@ enable enable Umbraco.Cms.Web.UI + false diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 49f18af1bd0d..3d8b1f9bdd04 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -8,8 +8,9 @@ false $(EnablePackageValidation) false + false - + annotations diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts index 854cbe257284..fce5f314442f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts @@ -5,6 +5,8 @@ const dataTypeName = 'Richtext editor'; let dataTypeDefaultData = null; let dataTypeData = null; +// Create tests for TinyMCE and TipTap + test.beforeEach(async ({umbracoUi, umbracoApi}) => { await umbracoUi.goToBackOffice(); await umbracoUi.dataType.goToSettingsTreeItem('Data Types'); @@ -13,8 +15,8 @@ test.beforeEach(async ({umbracoUi, umbracoApi}) => { test.afterEach(async ({umbracoApi}) => { if (dataTypeDefaultData !== null) { - await umbracoApi.dataType.update(dataTypeDefaultData.id, dataTypeDefaultData); - } + await umbracoApi.dataType.update(dataTypeDefaultData.id, dataTypeDefaultData); + } }); test('can enable ignore user start nodes', async ({umbracoApi, umbracoUi}) => { @@ -74,7 +76,7 @@ test.skip('can add stylesheet', async ({umbracoApi, umbracoUi}) => { }; await umbracoUi.dataType.goToDataType(dataTypeName); - + // Act await umbracoUi.dataType.addStylesheet(stylesheetName); await umbracoUi.dataType.clickSaveButton(); @@ -145,7 +147,8 @@ test('can select overlay size', async ({umbracoApi, umbracoUi}) => { expect(dataTypeData.values).toContainEqual(expectedDataTypeValues); }); -test('can enable hide label', async ({umbracoApi, umbracoUi}) => { +// No hide label for TipTap +test.skip('can enable hide label', async ({umbracoApi, umbracoUi}) => { // Arrange const expectedDataTypeValues = { "alias": "hideLabel", @@ -185,7 +188,8 @@ test('can add image upload folder', async ({umbracoApi, umbracoUi}) => { await umbracoApi.media.ensureNameNotExists(mediaFolderName); }); -test('can enable inline editing mode', async ({umbracoApi, umbracoUi}) => { +// There is no inline editing mode for TipTap +test.skip('can enable inline editing mode', async ({umbracoApi, umbracoUi}) => { // Arrange const mode = 'Inline'; const expectedDataTypeValues = { diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Dictionary/Dictionary.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Dictionary/Dictionary.spec.ts index 7581347e6871..16010eaaef07 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Dictionary/Dictionary.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Dictionary/Dictionary.spec.ts @@ -59,7 +59,9 @@ test('can create a dictionary item in a dictionary', {tag: '@smoke'}, async ({um // Act await umbracoUi.dictionary.clickActionsMenuForDictionary(parentDictionaryName); + await umbracoUi.waitForTimeout(500); await umbracoUi.dictionary.clickCreateButton(); + await umbracoUi.waitForTimeout(500); await umbracoUi.dictionary.enterDictionaryName(dictionaryName); await umbracoUi.dictionary.clickSaveButton(); diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index ec4d0e2600a5..39e6c8ac044b 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -172,7 +172,7 @@ protected void ConfigureServices(IServiceCollection services) .AddCoreMappingProfiles(); } - services.RemoveAll(x=>x.ImplementationType == typeof(DocumentUrlServiceInitializer)); + services.RemoveAll(x=>x.ImplementationType == typeof(DocumentUrlServiceInitializerNotificationHandler)); services.AddSignalR(); services.AddMvc(); diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs index 2ff15c353991..77c65ab10b50 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs @@ -35,7 +35,7 @@ public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest protected ContentType ContentType { get; private set; } [SetUp] - public void Setup() => CreateTestData(); + public virtual void Setup() => CreateTestData(); public virtual void CreateTestData() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs index 5ef6de9a9249..585317d5b611 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs @@ -1,23 +1,13 @@ -using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.Handlers; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Infrastructure.DependencyInjection; -using Umbraco.Cms.Infrastructure.Examine; -using Umbraco.Cms.Infrastructure.Examine.DependencyInjection; -using Umbraco.Cms.Infrastructure.HostedServices; -using Umbraco.Cms.Infrastructure.Search; -using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; -using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; @@ -33,12 +23,15 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) builder.Services.AddUnique(); builder.AddNotificationHandler(); - builder.Services.AddHostedService(); - + builder.Services.AddNotificationAsyncHandler(); } - + public override void Setup() + { + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); + base.Setup(); + } // // [Test] // [LongRunning] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs index c38bd544bc70..41e3f1897983 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs @@ -29,8 +29,14 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) builder.Services.AddUnique(); builder.AddNotificationHandler(); - builder.Services.AddHostedService(); } + + public override void Setup() + { + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); + base.Setup(); + } + [Test] [TestCase("/textpage/", "en-US", true, ExpectedResult = TextpageKey)] [TestCase("/textpage/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs index fd852afa86dc..3313d83cd389 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/BackOfficeExamineSearcherTests.cs @@ -5,13 +5,11 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.Examine.DependencyInjection; using Umbraco.Cms.Infrastructure.HostedServices; @@ -19,8 +17,6 @@ using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; -using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; using Umbraco.Cms.Web.Common.Security; namespace Umbraco.Cms.Tests.Integration.Umbraco.Examine.Lucene.UmbracoExamine; @@ -37,7 +33,9 @@ public void Setup() var httpContext = new DefaultHttpContext(); httpContext.RequestServices = Services; Mock.Get(TestHelper.GetHttpContextAccessor()).Setup(x => x.HttpContext).Returns(httpContext); - } + + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); + } [TearDown] public void TearDown() @@ -48,6 +46,7 @@ public void TearDown() Services.DisposeIfDisposable(); } + private IDocumentUrlService DocumentUrlService => GetRequiredService(); private IBackOfficeExamineSearcher BackOfficeExamineSearcher => GetRequiredService(); private IContentTypeService ContentTypeService => GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs index 66e9ad85fcec..4d2fb1530b34 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineBaseTest.cs @@ -44,9 +44,8 @@ protected override void CustomTestSetup(IUmbracoBuilder builder) builder .AddNotificationHandler(); - builder.Services.AddHostedService(); - } + /// /// Used to create and manage a testable index /// diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs index 24c04b643d48..3859f5fd1408 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/ExamineExternalIndexTests.cs @@ -37,6 +37,8 @@ public void Setup() var httpContext = new DefaultHttpContext(); httpContext.RequestServices = Services; Mock.Get(TestHelper.GetHttpContextAccessor()).Setup(x => x.HttpContext).Returns(httpContext); + + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); } [TearDown] @@ -52,6 +54,7 @@ public void TearDown() private IExamineExternalIndexSearcherTest ExamineExternalIndexSearcher => GetRequiredService(); + private IDocumentUrlService DocumentUrlService => GetRequiredService(); private IContentTypeService ContentTypeService => GetRequiredService(); private ContentService ContentService => (ContentService)GetRequiredService(); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexTest.cs index 84819f89ac8c..856b0e8cf272 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/IndexTest.cs @@ -3,6 +3,7 @@ using Lucene.Net.Util; using NUnit.Framework; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; @@ -18,6 +19,15 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Examine.Lucene.UmbracoExamine; [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)] public class IndexTest : ExamineBaseTest { + private IDocumentUrlService DocumentUrlService => GetRequiredService(); + + [SetUp] + public void Setup() + { + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); + } + + [Test] [LongRunning] public void GivenValidationParentNode_WhenContentIndexedUnderDifferentParent_DocumentIsNotIndexed() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/SearchTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/SearchTests.cs index 2e360ca4cfb7..885c7d685115 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/SearchTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Examine.Lucene/UmbracoExamine/SearchTests.cs @@ -18,6 +18,15 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Examine.Lucene.UmbracoExamine; [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)] public class SearchTests : ExamineBaseTest { + private IDocumentUrlService DocumentUrlService => GetRequiredService(); + + [SetUp] + public void Setup() + { + DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); + } + + [Test] [LongRunning] public void Test_Sort_Order_Sorting() diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Indexing.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Indexing.cs new file mode 100644 index 000000000000..6058676f69e1 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Indexing.cs @@ -0,0 +1,399 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; + +public partial class BlockListElementLevelVariationTests +{ + [Test] + public async Task Can_Index_Cultures_Independently_Invariant_Blocks() + { + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType); + + var content = CreateContent( + contentType, + elementType, + new List + { + new() { Alias = "invariantText", Value = "The invariant content value" }, + new() { Alias = "variantText", Value = "The content value (en-US)", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The content value (da-DK)", Culture = "da-DK" }, + }, + new List + { + new() { Alias = "invariantText", Value = "The invariant settings value" }, + new() { Alias = "variantText", Value = "The settings value (en-US)", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The settings value (da-DK)", Culture = "da-DK" }, + }, + true); + + var editor = blockListDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: true, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(2, indexValues.Count()); + + AssertIndexValues("en-US"); + AssertIndexValues("da-DK"); + + void AssertIndexValues(string culture) + { + var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(2, values.Length); + Assert.Contains($"The content value ({culture})", values); + Assert.Contains("The invariant content value", values); + } + } + + [Test] + public async Task Can_Index_Cultures_Independently_Variant_Blocks() + { + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType, ContentVariation.Culture); + + var content = CreateContent( + contentType, + elementType, + new [] + { + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "en-US invariantText content value" }, + new() { Alias = "variantText", Value = "en-US variantText content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "en-US invariantText settings value" }, + new() { Alias = "variantText", Value = "en-US variantText settings value" } + }, + "en-US", + null), + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "da-DK invariantText content value" }, + new() { Alias = "variantText", Value = "da-DK variantText content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "da-DK invariantText settings value" }, + new() { Alias = "variantText", Value = "da-DK variantText settings value" } + }, + "da-DK", + null) + }, + true); + + AssertIndexValues("en-US"); + AssertIndexValues("da-DK"); + + void AssertIndexValues(string culture) + { + var editor = blockListDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: culture, + segment: null, + published: true, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(1, indexValues.Count()); + + var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + Assert.AreEqual($"{culture} invariantText content value {culture} variantText content value", TrimAndStripNewlines(indexedValue)); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Handle_Unexposed_Blocks(bool published) + { + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType); + + var content = CreateContent(contentType, elementType, [], false); + var blockListValue = BlockListPropertyValue( + elementType, + [ + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#1: The invariant content value" }, + new() { Alias = "variantText", Value = "#1: The content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#1: The content value in Danish", Culture = "da-DK" } + }, + [], + null, + null + ) + ), + ( + Guid.NewGuid(), + Guid.NewGuid(), + new BlockProperty( + new List { + new() { Alias = "invariantText", Value = "#2: The invariant content value" }, + new() { Alias = "variantText", Value = "#2: The content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "#2: The content value in Danish", Culture = "da-DK" } + }, + [], + null, + null + ) + ) + ] + ); + + // only expose the first block in English and the second block in Danish (to make a difference between published and unpublished index values) + blockListValue.Expose = + [ + new() { ContentKey = blockListValue.ContentData[0].Key, Culture = "en-US" }, + new() { ContentKey = blockListValue.ContentData[1].Key, Culture = "da-DK" }, + ]; + + content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue)); + ContentService.Save(content); + + PublishContent(content, contentType, ["en-US", "da-DK"]); + + var editor = blockListDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: published, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(2, indexValues.Count()); + var indexValue = indexValues.FirstOrDefault(v => v.Culture == "da-DK"); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + if (published) + { + Assert.AreEqual("#2: The invariant content value #2: The content value in Danish", TrimAndStripNewlines(indexedValue)); + } + else + { + Assert.AreEqual("#1: The invariant content value #1: The content value in Danish #2: The invariant content value #2: The content value in Danish", TrimAndStripNewlines(indexedValue)); + } + + indexValue = indexValues.FirstOrDefault(v => v.Culture == "en-US"); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + if (published) + { + Assert.AreEqual("#1: The content value in English #1: The invariant content value", TrimAndStripNewlines(indexedValue)); + } + else + { + Assert.AreEqual("#1: The invariant content value #1: The content value in English #2: The invariant content value #2: The content value in English", TrimAndStripNewlines(indexedValue)); + } + } + + [TestCase(ContentVariation.Nothing)] + [TestCase(ContentVariation.Culture)] + public async Task Can_Index_Invariant(ContentVariation elementTypeVariation) + { + var elementType = CreateElementType(elementTypeVariation); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Nothing, blockListDataType); + + var content = CreateContent( + contentType, + elementType, + new List + { + new() { Alias = "invariantText", Value = "The invariant content value" }, + new() { Alias = "variantText", Value = "Another invariant content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "The invariant settings value" }, + new() { Alias = "variantText", Value = "Another invariant settings value" } + }, + true); + + var editor = blockListDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: true, + availableCultures: ["en-US"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(1, indexValues.Count()); + var indexValue = indexValues.FirstOrDefault(v => v.Culture is null); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(2, values.Length); + Assert.Contains("The invariant content value", values); + Assert.Contains("Another invariant content value", values); + } + + [Test] + public async Task Can_Index_Cultures_Independently_Nested_Invariant_Blocks() + { + var nestedElementType = CreateElementType(ContentVariation.Culture); + var nestedBlockListDataType = await CreateBlockListDataType(nestedElementType); + + var rootElementType = new ContentTypeBuilder() + .WithAlias("myRootElementType") + .WithName("My Root Element Type") + .WithIsElement(true) + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("invariantText") + .WithName("Invariant text") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Nothing) + .Done() + .AddPropertyType() + .WithAlias("variantText") + .WithName("Variant text") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .AddPropertyType() + .WithAlias("nestedBlocks") + .WithName("Nested blocks") + .WithDataTypeId(nestedBlockListDataType.Id) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.BlockList) + .WithValueStorageType(ValueStorageType.Ntext) + .WithVariations(ContentVariation.Nothing) + .Done() + .Build(); + ContentTypeService.Save(rootElementType); + var rootBlockListDataType = await CreateBlockListDataType(rootElementType); + var contentType = CreateContentType(ContentVariation.Culture, rootBlockListDataType); + + var nestedElementContentKey = Guid.NewGuid(); + var nestedElementSettingsKey = Guid.NewGuid(); + var content = CreateContent( + contentType, + rootElementType, + new List + { + new() + { + Alias = "nestedBlocks", + Value = BlockListPropertyValue( + nestedElementType, + nestedElementContentKey, + nestedElementSettingsKey, + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "The first nested invariant content value" }, + new() { Alias = "variantText", Value = "The first nested content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The first nested content value in Danish", Culture = "da-DK" }, + }, + new List + { + new() { Alias = "invariantText", Value = "The first nested invariant settings value" }, + new() { Alias = "variantText", Value = "The first nested settings value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The first nested settings value in Danish", Culture = "da-DK" }, + }, + null, + null)) + }, + new() { Alias = "invariantText", Value = "The first root invariant content value" }, + new() { Alias = "variantText", Value = "The first root content value in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "The first root content value in Danish", Culture = "da-DK" }, + }, + [], + true); + + var editor = rootBlockListDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: true, + availableCultures: ["en-US"], + contentTypeDictionary: new Dictionary + { + { nestedElementType.Key, nestedElementType }, { rootElementType.Key, rootElementType }, { contentType.Key, contentType } + }); + Assert.AreEqual(2, indexValues.Count()); + + AssertIndexedValues( + "en-US", + "The first root invariant content value", + "The first root content value in English", + "The first nested invariant content value", + "The first nested content value in English"); + + AssertIndexedValues( + "da-DK", + "The first root invariant content value", + "The first root content value in Danish", + "The first nested invariant content value", + "The first nested content value in Danish"); + + void AssertIndexedValues(string culture, params string[] expectedIndexedValues) + { + var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(expectedIndexedValues.Length, values.Length); + Assert.IsTrue(values.ContainsAll(expectedIndexedValues)); + } + } + + private string TrimAndStripNewlines(string value) + => value.Replace(Environment.NewLine, " ").Trim(); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/PropertyIndexValueFactoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/PropertyIndexValueFactoryTests.cs index ac386f10d2f6..b97af90ee110 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/PropertyIndexValueFactoryTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/PropertyIndexValueFactoryTests.cs @@ -83,12 +83,13 @@ public void Can_Get_Index_Values_From_RichText_With_Blocks() contentTypeDictionary: new Dictionary { { elementType.Key, elementType }, { contentType.Key, contentType } - }).ToDictionary(); + }); - Assert.IsTrue(indexValues.TryGetValue("bodyText", out var bodyTextIndexValues)); + var indexValue = indexValues.FirstOrDefault(v => v.FieldName == "bodyText"); + Assert.IsNotNull(indexValue); - Assert.AreEqual(1, bodyTextIndexValues.Count()); - var bodyTextIndexValue = bodyTextIndexValues.First() as string; + Assert.AreEqual(1, indexValue.Values.Count()); + var bodyTextIndexValue = indexValue.Values.First() as string; Assert.IsNotNull(bodyTextIndexValue); Assert.Multiple(() => @@ -122,12 +123,13 @@ public void Can_Get_Index_Values_From_RichText_Without_Blocks() contentTypeDictionary: new Dictionary { { contentType.Key, contentType } - }).ToDictionary(); + }); - Assert.IsTrue(indexValues.TryGetValue("bodyText", out var bodyTextIndexValues)); + var indexValue = indexValues.FirstOrDefault(v => v.FieldName == "bodyText"); + Assert.IsNotNull(indexValue); - Assert.AreEqual(1, bodyTextIndexValues.Count()); - var bodyTextIndexValue = bodyTextIndexValues.First() as string; + Assert.AreEqual(1, indexValue.Values.Count()); + var bodyTextIndexValue = indexValue.Values.First() as string; Assert.IsNotNull(bodyTextIndexValue); Assert.IsTrue(bodyTextIndexValue.Contains("This is some markup")); } @@ -205,12 +207,13 @@ public async Task Can_Get_Index_Values_From_BlockList() contentTypeDictionary: new Dictionary { { elementType.Key, elementType }, { contentType.Key, contentType } - }).ToDictionary(); + }); - Assert.IsTrue(indexValues.TryGetValue("blocks", out var blocksIndexValues)); + var indexValue = indexValues.FirstOrDefault(v => v.FieldName == "blocks"); + Assert.IsNotNull(indexValue); - Assert.AreEqual(1, blocksIndexValues.Count()); - var blockIndexValue = blocksIndexValues.First() as string; + Assert.AreEqual(1, indexValue.Values.Count()); + var blockIndexValue = indexValue.Values.First() as string; Assert.IsNotNull(blockIndexValue); Assert.Multiple(() => @@ -333,12 +336,13 @@ public async Task Can_Get_Index_Values_From_BlockGrid() contentTypeDictionary: new Dictionary { { elementType.Key, elementType }, { contentType.Key, contentType } - }).ToDictionary(); + }); - Assert.IsTrue(indexValues.TryGetValue("blocks", out var blocksIndexValues)); + var indexValue = indexValues.FirstOrDefault(v => v.FieldName == "blocks"); + Assert.IsNotNull(indexValue); - Assert.AreEqual(1, blocksIndexValues.Count()); - var blockIndexValue = blocksIndexValues.First() as string; + Assert.AreEqual(1, indexValue.Values.Count()); + var blockIndexValue = indexValue.Values.First() as string; Assert.IsNotNull(blockIndexValue); Assert.Multiple(() => diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextElementLevelVariationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextElementLevelVariationTests.cs index ad40548aac2d..80d02caa1552 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextElementLevelVariationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextElementLevelVariationTests.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -22,8 +23,8 @@ public async Task Can_Publish_Cultures_Independently() { var elementType = CreateElementType(ContentVariation.Culture); - var blockGridDataType = await CreateRichTextDataType(elementType); - var contentType = CreateContentType(blockGridDataType); + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); var richTextValue = CreateRichTextValue(elementType); var content = CreateContent(contentType, richTextValue); @@ -180,8 +181,8 @@ public async Task Can_Publish_With_Blocks_Removed() { var elementType = CreateElementType(ContentVariation.Culture); - var blockGridDataType = await CreateRichTextDataType(elementType); - var contentType = CreateContentType(blockGridDataType); + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); var richTextValue = CreateRichTextValue(elementType); var content = CreateContent(contentType, richTextValue); @@ -285,8 +286,8 @@ public async Task Markup_Follows_Invariance() { var elementType = CreateElementType(ContentVariation.Culture); - var blockGridDataType = await CreateRichTextDataType(elementType); - var contentType = CreateContentType(blockGridDataType); + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); var richTextValue = CreateRichTextValue(elementType); var content = CreateContent(contentType, richTextValue); @@ -369,8 +370,8 @@ public async Task Can_Publish_Without_Blocks_Variant() { var elementType = CreateElementType(ContentVariation.Culture); - var blockGridDataType = await CreateRichTextDataType(elementType); - var contentType = CreateContentType(blockGridDataType); + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); var richTextValue = new RichTextEditorValue { Markup = "

Markup here

", Blocks = null }; var content = CreateContent(contentType, richTextValue); @@ -398,8 +399,8 @@ public async Task Can_Publish_Without_Blocks_Invariant() { var elementType = CreateElementType(ContentVariation.Culture); - var blockGridDataType = await CreateRichTextDataType(elementType); - var contentType = CreateContentType(ContentVariation.Nothing, blockGridDataType); + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(ContentVariation.Nothing, rteDataType); var richTextValue = new RichTextEditorValue { Markup = "

Markup here

", Blocks = null }; var content = CreateContent(contentType, richTextValue); @@ -422,6 +423,247 @@ void AssertPropertyValues(string culture) } } + [Test] + public async Task Can_Index_Cultures_Independently_Invariant_Blocks() + { + var elementType = CreateElementType(ContentVariation.Culture); + + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); + var richTextValue = CreateRichTextValue(elementType); + var content = CreateContent(contentType, richTextValue); + PublishContent(content, ["en-US", "da-DK"]); + + var editor = rteDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: true, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(3, indexValues.Count()); + Assert.NotNull(indexValues.FirstOrDefault(value => value.FieldName.StartsWith(UmbracoExamineFieldNames.RawFieldPrefix))); + + AssertIndexedValues( + "en-US", + "Some text.", + "More text.", + "Even more text.", + "The end.", + "#1: The first invariant content value", + "#1: The first content value in English", + "#2: The first invariant content value", + "#2: The first content value in English", + "#3: The first invariant content value", + "#3: The first content value in English"); + + AssertIndexedValues( + "da-DK", + "Some text.", + "More text.", + "Even more text.", + "The end.", + "#1: The first invariant content value", + "#1: The first content value in Danish", + "#2: The first invariant content value", + "#2: The first content value in Danish", + "#3: The first invariant content value", + "#3: The first content value in Danish"); + + void AssertIndexedValues(string culture, params string[] expectedIndexedValues) + { + var indexValue = indexValues.FirstOrDefault(v => v.Culture.InvariantEquals(culture)); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + Assert.AreEqual(expectedIndexedValues.Length, values.Length); + Assert.IsTrue(values.ContainsAll(expectedIndexedValues)); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Index_With_Unexposed_Blocks(bool published) + { + var elementType = CreateElementType(ContentVariation.Culture); + + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(rteDataType); + var richTextValue = CreateRichTextValue(elementType); + richTextValue.Blocks!.Expose.RemoveAll(e => e.Culture == "da-DK"); + + var content = CreateContent(contentType, richTextValue); + PublishContent(content, ["en-US", "da-DK"]); + + var editor = rteDataType.Editor!; + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: null, + segment: null, + published: published, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(3, indexValues.Count()); + Assert.NotNull(indexValues.FirstOrDefault(value => value.FieldName.StartsWith(UmbracoExamineFieldNames.RawFieldPrefix))); + + if (published) + { + AssertIndexedValues( + "da-DK", + "Some text.", + "More text.", + "Even more text.", + "The end."); + } + else + { + AssertIndexedValues( + "da-DK", + "Some text.", + "More text.", + "Even more text.", + "The end.", + "#1: The first invariant content value", + "#1: The first content value in Danish", + "#2: The first invariant content value", + "#2: The first content value in Danish", + "#3: The first invariant content value", + "#3: The first content value in Danish"); + } + + AssertIndexedValues( + "en-US", + "Some text.", + "More text.", + "Even more text.", + "The end.", + "#1: The first invariant content value", + "#1: The first content value in English", + "#2: The first invariant content value", + "#2: The first content value in English", + "#3: The first invariant content value", + "#3: The first content value in English"); + + void AssertIndexedValues(string culture, params string[] expectedIndexedValues) + { + var indexValue = indexValues.FirstOrDefault(v => v.Culture.InvariantEquals(culture)); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + Assert.AreEqual(expectedIndexedValues.Length, values.Length); + Assert.IsTrue(values.ContainsAll(expectedIndexedValues)); + } + } + + [TestCase(ContentVariation.Culture)] + [TestCase(ContentVariation.Nothing)] + public async Task Can_Index_Cultures_Independently_Variant_Blocks(ContentVariation elementTypeVariation) + { + var elementType = CreateElementType(elementTypeVariation); + + var rteDataType = await CreateRichTextDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, rteDataType, ContentVariation.Culture); + + var englishRichTextValue = CreateInvariantRichTextValue("en-US"); + var danishRichTextValue = CreateInvariantRichTextValue("da-DK"); + + var content = CreateContent(contentType); + content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(englishRichTextValue), "en-US"); + content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(danishRichTextValue), "da-DK"); + ContentService.Save(content); + + PublishContent(content, ["en-US", "da-DK"]); + + var editor = rteDataType.Editor!; + + AssertIndexedValues( + "en-US", + "Some text for en-US.", + "More text for en-US.", + "invariantText value for en-US", + "variantText value for en-US"); + + AssertIndexedValues( + "da-DK", + "Some text for da-DK.", + "More text for da-DK.", + "invariantText value for da-DK", + "variantText value for da-DK"); + + void AssertIndexedValues(string culture, params string[] expectedIndexedValues) + { + var indexValues = editor.PropertyIndexValueFactory.GetIndexValues( + content.Properties["blocks"]!, + culture: culture, + segment: null, + published: true, + availableCultures: ["en-US", "da-DK"], + contentTypeDictionary: new Dictionary + { + { elementType.Key, elementType }, { contentType.Key, contentType } + }); + + Assert.AreEqual(2, indexValues.Count()); + Assert.NotNull(indexValues.FirstOrDefault(value => value.FieldName.StartsWith(UmbracoExamineFieldNames.RawFieldPrefix))); + + var indexValue = indexValues.FirstOrDefault(v => v.Culture.InvariantEquals(culture) && v.FieldName == "blocks"); + Assert.IsNotNull(indexValue); + Assert.AreEqual(1, indexValue.Values.Count()); + var indexedValue = indexValue.Values.First() as string; + Assert.IsNotNull(indexedValue); + var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + Assert.AreEqual(expectedIndexedValues.Length, values.Length); + Assert.IsTrue(values.ContainsAll(expectedIndexedValues)); + } + + RichTextEditorValue CreateInvariantRichTextValue(string culture) + { + var contentElementKey = Guid.NewGuid(); + return new RichTextEditorValue + { + Markup = $""" +

Some text for {culture}.

+ +

More text for {culture}.

+ """, + Blocks = new RichTextBlockValue([ + new RichTextBlockLayoutItem(contentElementKey) + ]) + { + ContentData = + [ + new(contentElementKey, elementType.Key, elementType.Alias) + { + Values = + [ + new() { Alias = "invariantText", Value = $"invariantText value for {culture}" }, + new() { Alias = "variantText", Value = $"variantText value for {culture}" } + ] + } + ], + SettingsData = [], + Expose = + [ + new(contentElementKey, culture, null), + ] + } + }; + } + } + private async Task CreateRichTextDataType(IContentType elementType) => await CreateBlockEditorDataType( Constants.PropertyEditors.Aliases.RichText, @@ -536,7 +778,7 @@ private RichTextEditorValue CreateRichTextValue(IContentType elementType) }; } - private IContent CreateContent(IContentType contentType, RichTextEditorValue richTextValue) + private IContent CreateContent(IContentType contentType, RichTextEditorValue? richTextValue = null) { var contentBuilder = new ContentBuilder() .WithContentType(contentType); @@ -554,8 +796,11 @@ private IContent CreateContent(IContentType contentType, RichTextEditorValue ric var content = contentBuilder.Build(); - var propertyValue = JsonSerializer.Serialize(richTextValue); - content.Properties["blocks"]!.SetValue(propertyValue); + if (richTextValue is not null) + { + var propertyValue = JsonSerializer.Serialize(richTextValue); + content.Properties["blocks"]!.SetValue(propertyValue); + } ContentService.Save(content); return content; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 24ff0983e220..84b018c4ab2b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -164,6 +164,9 @@ MediaTypeEditingServiceTests.cs + + BlockListElementLevelVariationTests.cs + BlockListElementLevelVariationTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs index 27df624130e1..deb26ef901bf 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentNavigationServiceBaseTests.cs @@ -123,6 +123,59 @@ public void Can_Get_Parent_From_Existing_Content_Key(Guid childKey, Guid? expect }); } + [Test] + public void Cannot_Get_Root_Items_When_Empty_Tree() + { + // Arrange + var emptyNavigationService = new TestContentNavigationService( + Mock.Of(), + Mock.Of()); + + // Act + emptyNavigationService.TryGetRootKeys(out IEnumerable rootKeys); + List rootsList = rootKeys.ToList(); + + // Assert + Assert.IsEmpty(rootsList); + } + + [Test] + public void Can_Get_Single_Root_Item() + { + // Act + var result = _navigationService.TryGetRootKeys(out IEnumerable rootKeys); + List rootsList = rootKeys.ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.IsNotEmpty(rootsList); + Assert.AreEqual(1, rootsList.Count); + Assert.IsTrue(rootsList.Contains(Root)); + }); + } + + [Test] + public void Can_Get_Root_Item_In_Correct_Order() + { + // Arrange + Guid anotherRoot = Guid.NewGuid(); + _navigationService.Add(anotherRoot); + + // Act + var result = _navigationService.TryGetRootKeys(out IEnumerable rootKeys); + List rootsList = rootKeys.ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.AreEqual(2, rootsList.Count); + CollectionAssert.AreEqual(new[] { Root, anotherRoot }, rootsList); // Root and Another root in order + }); + } + [Test] public void Cannot_Get_Children_From_Non_Existing_Content_Key() { @@ -352,7 +405,7 @@ public void Can_Get_Siblings_Of_Existing_Content_Key_Without_Self() public void Can_Get_Siblings_Of_Existing_Content_Key_At_Content_Root() { // Arrange - Guid anotherRoot = new Guid("716380B9-DAA9-4930-A461-95EF39EBAB41"); + Guid anotherRoot = Guid.NewGuid(); _navigationService.Add(anotherRoot); // Act @@ -411,6 +464,47 @@ public void Can_Get_Siblings_Of_Existing_Content_Key_In_Their_Order_Of_Creation( } } + [Test] + public void Cannot_Get_Level_From_Non_Existing_Content_Key() + { + // Arrange + var nonExistingKey = Guid.NewGuid(); + + // Act + var result = _navigationService.TryGetLevel(nonExistingKey, out var level); + + // Assert + Assert.Multiple(() => + { + Assert.IsFalse(result); + Assert.IsNull(level); + }); + } + + [Test] + [TestCase("E48DD82A-7059-418E-9B82-CDD5205796CF", 1)] // Root + [TestCase("C6173927-0C59-4778-825D-D7B9F45D8DDE", 2)] // Child 1 + [TestCase("E856AC03-C23E-4F63-9AA9-681B42A58573", 3)] // Grandchild 1 + [TestCase("A1B1B217-B02F-4307-862C-A5E22DB729EB", 3)] // Grandchild 2 + [TestCase("60E0E5C4-084E-4144-A560-7393BEAD2E96", 2)] // Child 2 + [TestCase("D63C1621-C74A-4106-8587-817DEE5FB732", 3)] // Grandchild 3 + [TestCase("56E29EA9-E224-4210-A59F-7C2C5C0C5CC7", 4)] // Great-grandchild 1 + [TestCase("B606E3FF-E070-4D46-8CB9-D31352029FDF", 2)] // Child 3 + [TestCase("F381906C-223C-4466-80F7-B63B4EE073F8", 3)] // Grandchild 4 + public void Can_Get_Level_From_Existing_Content_Key(Guid key, int expectedLevel) + { + // Act + var result = _navigationService.TryGetLevel(key, out var level); + + // Assert + Assert.Multiple(() => + { + Assert.IsTrue(result); + Assert.IsNotNull(level); + Assert.AreEqual(expectedLevel, level); + }); + } + [Test] public void Cannot_Move_Node_To_Bin_When_Non_Existing_Content_Key() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationTests.cs index 1e4ddd6f348d..f3ea130a68ac 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Migrations/MigrationTests.cs @@ -52,14 +52,14 @@ public IScope CreateDetachedScope( public ISqlContext SqlContext { get; set; } + public IScope AmbientScope { get; } + #if DEBUG_SCOPES - public ScopeInfo GetScopeInfo(IScope scope) - { - throw new NotImplementedException(); - } - public IEnumerable ScopeInfos => throw new NotImplementedException(); + public IEnumerable ScopeInfos => throw new NotImplementedException(); + + public ScopeInfo GetScopeInfo(IScope scope) => throw new NotImplementedException(); #endif - public IScope AmbientScope { get; } + } private class TestPlan : MigrationPlan