From 2704d4a34af9716e41ea50e35356b7323d602958 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:49:18 +0900 Subject: [PATCH] V15: Hybrid Caching (#16938) * Update to dotnet 9 and update nuget packages * Update umbraco code version * Update Directory.Build.props Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Include preview version in pipeline * update template projects * update global json with specific version * Update version.json to v15 * Rename TrimStart and TrimEnd to string specific * Rename to Exact * Update global.json Co-authored-by: Ronald Barendse * Remove includePreviewVersion * Rename to trim exact * Add new Hybridcache project * Add tests * Start implementing PublishedContent.cs * Implement repository for content * Refactor to use async everywhere * Add cache refresher * make public as needed for serialization * Use content type cache to get content type out * Refactor to use ContentCacheNode model, that goes in the memory cache * Remove content node kit as its not needed * Implement tests for ensuring caching * Implement better asserts * Implement published property * Refactor to use mapping * Rename to document tests * Update to test properties * Create more tests * Refactor mock tests into own file * Update property test * Fix published version of content * Change default cache level to elements * Refactor to always have draft * Refactor to not use PublishedModelFactory * Added tests * Added and updated tests * Fixed tests * Don't return empty object with id * More tests * Added key * Another key * Refactor CacheService to be responsible for using the hybrid cache * Use notification handler to remove deleted content from cache * Add more tests for missing functions * Implement missing methods * Remove HasContent as it pertains to routing * Fik up test * formatting * refactor variable names * Implement variant tests * Map all the published content properties * Get item out of cache first, to assert updated * Implement member cache * Add member test * Implement media cache * Implement property tests for media tests * Refactor tests to use extension method * Add more media tests * Refactor properties to no longer have element caching * Don't use property cache level * Start implementing seeding * Only seed when main * Add Immutable for performance * Implement permanent seeding of content * Implement cache settings * Implement tests for seeding * Update package version * start refactoring nurepo * Refactor so draft & published nodes are cached individually * Refactor RefreshContent to take node instead of IContent * Refactor media to also use cache nodes * Remove member from repo as it isn't cached * Refactor media to not include preview, as media has no draft * create new benchmark project * POC Integration benchmarks with custom api controllers * Start implementing content picker tests * Implement domain cache * Rework content cache to implement interface * Start implementing elements cache * Implement published snapshot service * Publish snapshot tests * Use snapshot for elements cache * Create test proving we don't clear cache when updating content picker * Clear entire elements cache * Remove properties from element cache, when content gets updated. * Rename methods to async * Refactor to use old cache interfaces instead of new ones * Remove snapshot, as it is no longer needed * Fix tests building * Refactor domaincache to not have snapshots * Delete benchmarks * Delete benchmarks * Add HybridCacheProject to Umbraco * Add comment to route value transformer * Implement is draft * remove snapshot from property * V15 updated the hybrid caching integration tests to use ContentEditingService (#16947) * Added builder extension withParentKey * Created builder with ContentEditingService * Added usage of the ContentEditingService to SETUP * Started using ContentEditingService builder in tests * Updated builder extensions * Fixed builder * Clean up * Clean up, not done * Added Ids * Remove entries from cache on delete * Fix up seeding logic * Don't register hybrid cache twice * Change seeded entry options * Update hybrid cache package * Fix up published property to work with delivery api again * Fix dependency injection to work with tests * Fix naming * Dont make caches nullable * Make content node sealed * Remove path and other unused from content node * Remove hacky 2 phase ctor * Refactor to actually set content templates * Remove umbraco context * Remove "HasBy" methods * rename property data * Delete obsolete legacy stuff * Add todo for making expiration configurable * Add todo in UmbracoContext * Add clarifying comment in content factory * Remove xml stuff from published property * Fix according to review * Make content type cache injectible * Make content type cache injectible * Rename to database cache repository * Rename to document cache * Add TODO * Refactor to async * Rename to async * Make everything async * Remove duplicate line from json schema * Move Hybrid cache project * Remove leftover file * Refactor to use keys * Refactor published content to no longer have content data, as it is on the node itself * Refactor to member to use proper content node ctor * Move tests to own folder * Add immutable objects to property and content data for performance * Make property data public * Fix member caching to be singleton * Obsolete GetContentType * Remove todo * Fix naming * Fix lots of exposed errors due to scope test * Add final scope tests * Rename to document cache service * Rename test files * Create new doc type tests * Add ignore to tests * Start implementing refresh for content type save * Clear contenttype cache when contenttype is updated * Fix test Teh contenttype is not upated unless the property is dirty * Use init for ContentSourceDto * Fix get by key in PublishedContentTypeCache * Remove ContentType from PublishedContentTypeCache when contenttype is deleted * Update to preview 7 * Fix versions * Increase timeout for sqlite integration tests * Undo timeout increase * Try and undo init change to ContentSourceDto * That wasn't it chief * Try and make DomainAndUrlsTests non NonParallelizable * Update versions * Only run cache tests on linux for now --------- Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Co-authored-by: Ronald Barendse Co-authored-by: Andreas Zerbst Co-authored-by: Sven Geusens Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Co-authored-by: nikolajlauridsen --- Directory.Packages.props | 42 +- global.json | 2 +- .../UmbracoBuilder.BackOffice.cs | 1 + .../Umbraco.Cms.Api.Management.csproj | 1 + .../Implement/DomainCacheRefresher.cs | 4 +- src/Umbraco.Core/Constants-Configuration.cs | 1 + .../UmbracoBuilder.Configuration.cs | 5 +- src/Umbraco.Core/Models/CacheSettings.cs | 13 + .../PublishedContent/IPublishedMember.cs | 22 + .../PublishedContent/IPublishedMemberCache.cs | 10 +- .../PublishedContent/PublishedPropertyType.cs | 2 +- .../ContentPickerPropertyEditor.cs | 2 +- .../PropertyEditors/PropertyCacheLevel.cs | 1 + .../ContentPickerValueConverter.cs | 11 +- .../PublishedCache/ICacheManager.cs | 37 + .../PublishedCache/IDomainCacheService.cs | 31 + .../PublishedCache/IPublishedCache.cs | 27 +- .../PublishedCache/IPublishedContentCache.cs | 23 + .../IPublishedContentTypeCache.cs | 47 + .../PublishedCache/IPublishedMediaCache.cs | 17 + .../Internal/InternalPublishedContentCache.cs | 11 + .../Templates/HtmlLocalLinkParser.cs | 30 +- src/Umbraco.Core/Web/IUmbracoContext.cs | 1 + .../Persistence/NPocoDatabaseExtensions.cs | 17 +- .../Persistence/NPocoSqlExtensions.cs | 15 + .../PublishedContentTypeCache.cs | 35 +- .../CacheManager.cs | 27 + .../ContentCacheNode.cs | 24 + .../ContentData.cs | 45 + .../ContentNode.cs | 61 ++ .../CultureVariation.cs | 29 + .../UmbracoBuilderExtensions.cs | 67 ++ .../DocumentCache.cs | 64 ++ .../DomainCache.cs | 34 + .../ElementsDictionaryAppCache.cs | 7 + .../Factories/CacheNodeFactory.cs | 120 +++ .../Factories/ICacheNodeFactory.cs | 9 + .../Factories/IPublishedContentFactory.cs | 12 + .../Factories/PublishedContentFactory.cs | 159 ++++ .../IElementsCache.cs | 7 + .../MediaCache.cs | 56 ++ .../MemberCache.cs | 27 + .../CacheRefreshingNotificationHandler.cs | 124 +++ .../SeedingNotificationHandler.cs | 21 + .../Persistence/ContentSourceDto.cs | 67 ++ .../Persistence/DatabaseCacheRepository.cs | 895 ++++++++++++++++++ .../Persistence/IDatabaseCacheRepository.cs | 57 ++ .../PropertyData.cs | 43 + .../PublishedContent.cs | 195 ++++ .../PublishedMember.cs | 38 + .../PublishedProperty.cs | 330 +++++++ .../Serialization/ContentCacheDataModel.cs | 30 + .../ContentCacheDataSerializationResult.cs | 47 + .../ContentCacheDataSerializerEntityType.cs | 9 + .../IContentCacheDataSerializer.cs | 22 + .../IContentCacheDataSerializerFactory.cs | 15 + .../JsonContentNestedDataSerializer.cs | 39 + .../JsonContentNestedDataSerializerFactory.cs | 8 + .../Serialization/LazyCompressedString.cs | 105 ++ ...ctionaryStringInternIgnoreCaseFormatter.cs | 27 + .../MsgPackContentNestedDataSerializer.cs | 147 +++ ...gPackContentNestedDataSerializerFactory.cs | 69 ++ .../Services/DocumentCacheService.cs | 174 ++++ .../Services/DomainCacheService.cs | 111 +++ .../Services/IDocumentCacheService.cs | 21 + .../Services/IMediaCacheService.cs | 17 + .../Services/IMemberCacheService.cs | 9 + .../Services/MediaCacheService.cs | 120 +++ .../Services/MemberCacheService.cs | 15 + .../Umbraco.PublishedCache.HybridCache.csproj | 35 + .../ContentCache.cs | 6 + .../MediaCache.cs | 6 + .../MemberCache.cs | 7 +- .../Persistence/NuCacheContentRepository.cs | 1 + .../PublishedMember.cs | 2 +- .../Routing/UmbracoRouteValueTransformer.cs | 1 + tests/Directory.Packages.props | 16 +- .../Builders/ContentEditingBuilder.cs | 172 ++++ .../ContentEditingBuilderExtensions.cs | 58 ++ .../IWithContentTypeKeyBuilder.cs | 6 + .../IWithInvariantNameBuilder.cs | 6 + .../IWithInvariantPropertiesBuilder.cs | 8 + .../IWithParentKeyBuilder.cs | 9 + .../IWithTemplateKeyBuilder.cs | 6 + .../IWithVariantsBuilder.cs | 8 + .../UmbracoTestServerTestBase.cs | 3 +- ...mbracoIntegrationTestWithContentEditing.cs | 133 +++ .../DocumentHybridCacheDocumentTypeTests.cs | 82 ++ .../DocumentHybridCacheMockTests.cs | 205 ++++ .../DocumentHybridCachePropertyTest.cs | 185 ++++ .../DocumentHybridCacheScopeTests.cs | 85 ++ .../DocumentHybridCacheTests.cs | 518 ++++++++++ .../DocumentHybridCacheVariantsTests.cs | 193 ++++ .../MediaHybridCacheTests.cs | 239 +++++ .../MemberHybridCacheTests.cs | 81 ++ .../Umbraco.Tests.Integration.csproj | 1 + .../UrlAndDomains/DomainAndUrlsTests.cs | 2 + .../ContentPickerValueConverterTests.cs | 2 +- .../MarkdownEditorValueConverterTests.cs | 3 +- .../OutputExpansionStrategyTestBase.cs | 2 +- .../Templates/HtmlLocalLinkParserTests.cs | 13 +- umbraco.sln | 8 + 102 files changed, 5881 insertions(+), 132 deletions(-) create mode 100644 src/Umbraco.Core/Models/CacheSettings.cs create mode 100644 src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs create mode 100644 src/Umbraco.Core/PublishedCache/ICacheManager.cs create mode 100644 src/Umbraco.Core/PublishedCache/IDomainCacheService.cs create mode 100644 src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/CacheManager.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/ContentData.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/ContentNode.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/DomainCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/MediaCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/MemberCache.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/PropertyData.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataModel.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializationResult.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializerEntityType.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializer.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializerFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializer.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializerFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/LazyCompressedString.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializer.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializerFactory.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/IDocumentCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/IMediaCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Services/MemberCacheService.cs create mode 100644 src/Umbraco.PublishedCache.HybridCache/Umbraco.PublishedCache.HybridCache.csproj create mode 100644 tests/Umbraco.Tests.Common/Builders/ContentEditingBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Extensions/ContentEditingBuilderExtensions.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithContentTypeKeyBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantNameBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantPropertiesBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithParentKeyBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithTemplateKeyBuilder.cs create mode 100644 tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithVariantsBuilder.cs create mode 100644 tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheDocumentTypeTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCachePropertyTest.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheScopeTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheVariantsTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaHybridCacheTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberHybridCacheTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 01e3bf1628a0..5f2a028c77fe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,27 +12,29 @@ - + - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -83,7 +85,7 @@ - + diff --git a/global.json b/global.json index 5db4761d4604..a718288b1ae2 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100-preview.5.24307.3", + "version": "9.0.100-preview.7.24407.12", "rollForward": "latestFeature", "allowPrerelease": true } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs index 907e3057af6c..f77afa73478d 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs @@ -35,6 +35,7 @@ public static IUmbracoBuilder .AddWebServer() .AddRecurringBackgroundJobs() .AddNuCache() + .AddUmbracoHybridCache() .AddDistributedCache() .AddCoreNotifications() .AddExamine() diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index 37ab3611deb9..9ff61d58fd47 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs index a6e46ee2e48b..9c5030e55361 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs @@ -17,8 +17,10 @@ public DomainCacheRefresher( IPublishedSnapshotService publishedSnapshotService, IEventAggregator eventAggregator, ICacheRefresherNotificationFactory factory) - : base(appCaches, serializer, eventAggregator, factory) => + : base(appCaches, serializer, eventAggregator, factory) + { _publishedSnapshotService = publishedSnapshotService; + } #region Json diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 459becd32073..60b3397eeb29 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -65,6 +65,7 @@ public static class Configuration public const string ConfigDataTypes = ConfigPrefix + "DataTypes"; public const string ConfigPackageManifests = ConfigPrefix + "PackageManifests"; public const string ConfigWebhook = ConfigPrefix + "Webhook"; + public const string ConfigCache = ConfigPrefix + "Cache"; public static class NamedOptions { diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 6832bbe78971..6c771f502376 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -5,7 +5,7 @@ using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.Models.Validation; -using Umbraco.Extensions; +using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.DependencyInjection; @@ -85,7 +85,8 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Core/Models/CacheSettings.cs b/src/Umbraco.Core/Models/CacheSettings.cs new file mode 100644 index 000000000000..dcd72113474a --- /dev/null +++ b/src/Umbraco.Core/Models/CacheSettings.cs @@ -0,0 +1,13 @@ +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Core.Models; + +[UmbracoOptions(Constants.Configuration.ConfigCache)] +public class CacheSettings +{ + /// + /// Gets or sets a value for the collection of content type ids to always have in the cache. + /// + public List ContentTypeKeys { get; set; } = + new(); +} diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs new file mode 100644 index 000000000000..9095a4aaa389 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Core.Models.PublishedContent; + +public interface IPublishedMember : IPublishedContent +{ + public string Email { get; } + + public string UserName { get; } + + public string? Comments { get; } + + public bool IsApproved { get; } + + public bool IsLockedOut { get; } + + public DateTime? LastLockoutDate { get; } + + public DateTime CreationDate { get; } + + public DateTime? LastLoginDate { get; } + + public DateTime? LastPasswordChangedDate { get; } +} diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs index cefb51241ef2..11cb52a57ddf 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs @@ -10,7 +10,7 @@ public interface IPublishedMemberCache /// /// /// - IPublishedContent? Get(IMember member); + IPublishedMember? Get(IMember member); /// /// Gets a content type identified by its unique identifier. @@ -26,4 +26,12 @@ public interface IPublishedMemberCache /// The content type, or null. /// The alias is case-insensitive. IPublishedContentType GetContentType(string alias); + + /// + /// Get an from an + /// + /// The key of the member to fetch + /// Will fetch draft if this is set to true + /// + Task GetAsync(IMember member); } diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs index 398e855343ad..1c3818b59278 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs @@ -196,7 +196,7 @@ private void InitializeLocked() var deliveryApiPropertyValueConverter = _converter as IDeliveryApiPropertyValueConverter; - _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Snapshot; + _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Elements; _deliveryApiCacheLevel = deliveryApiPropertyValueConverter?.GetDeliveryApiPropertyCacheLevel(this) ?? _cacheLevel; _deliveryApiCacheLevelForExpansion = deliveryApiPropertyValueConverter?.GetDeliveryApiPropertyCacheLevelForExpansion(this) ?? _cacheLevel; _modelClrType = _converter?.GetPropertyValueType(this) ?? typeof(object); diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs index 34d9956e88cc..1c5d240c8f69 100644 --- a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs @@ -67,7 +67,7 @@ public IEnumerable GetReferences(object? value) // starting in v14 the passed in value is always a guid, we store it as a document Udi string. Else it's an invalid value public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) => editorValue.Value is not null - && Guid.TryParse(editorValue.Value as string, out Guid guidValue) + && Guid.TryParse(editorValue.Value.ToString(), out Guid guidValue) ? GuidUdi.Create(Constants.UdiEntityType.Document, guidValue).ToString() : null; diff --git a/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs b/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs index c835c0ae958d..82e246feb6ef 100644 --- a/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs +++ b/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs @@ -30,6 +30,7 @@ public enum PropertyCacheLevel /// In most cases, a snapshot is created per request, and therefore this is /// equivalent to cache the value for the duration of the request. /// + [Obsolete("Caching no longer supports snapshotting")] Snapshot = 3, /// diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs index 470a95e54ee0..972f7af03d04 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs @@ -17,14 +17,14 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery Constants.Conventions.Content.Redirect.ToLower(CultureInfo.InvariantCulture), }; - private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IPublishedContentCache _publishedContentCache; private readonly IApiContentBuilder _apiContentBuilder; public ContentPickerValueConverter( - IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedContentCache publishedContentCache, IApiContentBuilder apiContentBuilder) { - _publishedSnapshotAccessor = publishedSnapshotAccessor; + _publishedContentCache = publishedContentCache; _apiContentBuilder = apiContentBuilder; } @@ -105,10 +105,9 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType PropertiesToExclude.Contains(propertyType.Alias.ToLower(CultureInfo.InvariantCulture))) == false) { IPublishedContent? content; - IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); if (inter is int id) { - content = publishedSnapshot.Content?.GetById(id); + content = _publishedContentCache.GetById(id); if (content != null) { return content; @@ -121,7 +120,7 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType return null; } - content = publishedSnapshot.Content?.GetById(udi.Guid); + content = _publishedContentCache.GetById(udi.Guid); if (content != null && content.ContentType.ItemType == PublishedItemType.Content) { return content; diff --git a/src/Umbraco.Core/PublishedCache/ICacheManager.cs b/src/Umbraco.Core/PublishedCache/ICacheManager.cs new file mode 100644 index 000000000000..062a802adbbb --- /dev/null +++ b/src/Umbraco.Core/PublishedCache/ICacheManager.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Cache; + +namespace Umbraco.Cms.Core.PublishedCache; + +public interface ICacheManager +{ + /// + /// Gets the . + /// + IPublishedContentCache Content { get; } + + /// + /// Gets the . + /// + IPublishedMediaCache Media { get; } + + /// + /// Gets the . + /// + IPublishedMemberCache Members { get; } + + /// + /// Gets the . + /// + IDomainCache Domains { get; } + + /// + /// Gets the elements-level cache. + /// + /// + /// + /// The elements-level cache is shared by all snapshots relying on the same elements, + /// ie all snapshots built on top of unchanging content / media / etc. + /// + /// + IAppCache ElementsCache { get; } +} diff --git a/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs b/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs new file mode 100644 index 000000000000..5d856ff3497e --- /dev/null +++ b/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs @@ -0,0 +1,31 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Routing; + +namespace Umbraco.Cms.Core.PublishedCache; + +public interface IDomainCacheService +{ + /// + /// Gets all in the current domain cache, including any domains that may be referenced by + /// documents that are no longer published. + /// + /// + /// + IEnumerable GetAll(bool includeWildcards); + + /// + /// Gets all assigned for specified document, even if it is not published. + /// + /// The document identifier. + /// A value indicating whether to consider wildcard domains. + IEnumerable GetAssigned(int documentId, bool includeWildcards = false); + + /// + /// Determines whether a document has domains. + /// + /// The document identifier. + /// A value indicating whether to consider wildcard domains. + bool HasAssigned(int documentId, bool includeWildcards = false); + + void Refresh(DomainCacheRefresher.JsonPayload[] payloads); +} diff --git a/src/Umbraco.Core/PublishedCache/IPublishedCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedCache.cs index 0bf12d8fbb37..e4d8a2311c49 100644 --- a/src/Umbraco.Core/PublishedCache/IPublishedCache.cs +++ b/src/Umbraco.Core/PublishedCache/IPublishedCache.cs @@ -32,6 +32,7 @@ public interface IPublishedCache /// The content Udi identifier. /// The content, or null. /// The value of overrides defaults. + [Obsolete] // FIXME: Remove when replacing nucache IPublishedContent? GetById(bool preview, Udi contentId); /// @@ -56,25 +57,9 @@ public interface IPublishedCache /// The content unique identifier. /// The content, or null. /// Considers published or unpublished content depending on defaults. + [Obsolete] // FIXME: Remove when replacing nucache IPublishedContent? GetById(Udi contentId); - /// - /// Gets a value indicating whether the cache contains a specified content. - /// - /// A value indicating whether to consider unpublished content. - /// The content unique identifier. - /// A value indicating whether to the cache contains the specified content. - /// The value of overrides defaults. - bool HasById(bool preview, int contentId); - - /// - /// Gets a value indicating whether the cache contains a specified content. - /// - /// The content unique identifier. - /// A value indicating whether to the cache contains the specified content. - /// Considers published or unpublished content depending on defaults. - bool HasById(int contentId); - /// /// Gets contents at root. /// @@ -82,6 +67,7 @@ public interface IPublishedCache /// A culture. /// The contents. /// The value of overrides defaults. + [Obsolete] // FIXME: Remove when replacing nucache IEnumerable GetAtRoot(bool preview, string? culture = null); /// @@ -90,6 +76,7 @@ public interface IPublishedCache /// A culture. /// The contents. /// Considers published or unpublished content depending on defaults. + [Obsolete] // FIXME: Remove when replacing nucache IEnumerable GetAtRoot(string? culture = null); /// @@ -98,6 +85,7 @@ public interface IPublishedCache /// A value indicating whether to consider unpublished content. /// A value indicating whether the cache contains published content. /// The value of overrides defaults. + [Obsolete] // FIXME: Remove when replacing nucache bool HasContent(bool preview); /// @@ -105,6 +93,7 @@ public interface IPublishedCache /// /// A value indicating whether the cache contains published content. /// Considers published or unpublished content depending on defaults. + [Obsolete] // FIXME: Remove when replacing nucache bool HasContent(); /// @@ -112,6 +101,7 @@ public interface IPublishedCache /// /// The content type unique identifier. /// The content type, or null. + [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")] IPublishedContentType? GetContentType(int id); /// @@ -120,6 +110,7 @@ public interface IPublishedCache /// The content type alias. /// The content type, or null. /// The alias is case-insensitive. + [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")] IPublishedContentType? GetContentType(string alias); /// @@ -127,6 +118,7 @@ public interface IPublishedCache /// /// The content type. /// The contents. + [Obsolete] // FIXME: Remove when replacing nucache IEnumerable GetByContentType(IPublishedContentType contentType); /// @@ -134,5 +126,6 @@ public interface IPublishedCache /// /// The content type key. /// The content type, or null. + [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")] IPublishedContentType? GetContentType(Guid key); } diff --git a/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs index 6d5fa9b4e80b..8353225f1057 100644 --- a/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs +++ b/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs @@ -4,6 +4,25 @@ namespace Umbraco.Cms.Core.PublishedCache; public interface IPublishedContentCache : IPublishedCache { + /// + /// Gets a content identified by its unique identifier. + /// + /// The content unique identifier. + /// A value indicating whether to consider unpublished content. + /// The content, or null. + /// Considers published or unpublished content depending on defaults. + Task GetByIdAsync(int id, bool preview = false); + + /// + /// Gets a content identified by its unique identifier. + /// + /// The content unique identifier. + /// A value indicating whether to consider unpublished content. + /// The content, or null. + /// Considers published or unpublished content depending on defaults. + Task GetByIdAsync(Guid key, bool preview = false); + + // FIXME: All these routing methods needs to be removed, as they are no longer part of the content cache /// /// Gets content identified by a route. /// @@ -24,6 +43,7 @@ public interface IPublishedContentCache : IPublishedCache /// /// The value of overrides defaults. /// + [Obsolete] IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null); /// @@ -45,6 +65,7 @@ public interface IPublishedContentCache : IPublishedCache /// /// Considers published or unpublished content depending on defaults. /// + [Obsolete] IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null); /// @@ -62,6 +83,7 @@ public interface IPublishedContentCache : IPublishedCache /// /// The value of overrides defaults. /// + [Obsolete] string? GetRouteById(bool preview, int contentId, string? culture = null); /// @@ -76,5 +98,6 @@ public interface IPublishedContentCache : IPublishedCache /// for the current route. If a domain is present the string will be prefixed with the domain ID integer, example: /// {domainId}/route-path-of-item /// + [Obsolete] string? GetRouteById(int contentId, string? culture = null); } diff --git a/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs new file mode 100644 index 000000000000..318e7046c128 --- /dev/null +++ b/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs @@ -0,0 +1,47 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Core.PublishedCache; + +public interface IPublishedContentTypeCache +{ + /// + /// Clears the entire cache. + /// + public void ClearAll(); + + /// + /// Clears a cached content type. + /// + /// An identifier. + public void ClearContentType(int id); + + /// + /// Clears all cached content types referencing a data type. + /// + /// A data type identifier. + public void ClearDataType(int id); + + /// + /// Gets a published content type. + /// + /// An item type. + /// An key. + /// The published content type corresponding to the item key. + public IPublishedContentType Get(PublishedItemType itemType, Guid key); + + /// + /// Gets a published content type. + /// + /// An item type. + /// An alias. + /// The published content type corresponding to the item type and alias. + public IPublishedContentType Get(PublishedItemType itemType, string alias); + + /// + /// Gets a published content type. + /// + /// An item type. + /// An identifier. + /// The published content type corresponding to the item type and identifier. + public IPublishedContentType Get(PublishedItemType itemType, int id); +} diff --git a/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs index b0fd46748ed4..eb7810960725 100644 --- a/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs +++ b/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs @@ -1,5 +1,22 @@ +using Umbraco.Cms.Core.Models.PublishedContent; + namespace Umbraco.Cms.Core.PublishedCache; public interface IPublishedMediaCache : IPublishedCache { + /// + /// Gets a content identified by its unique identifier. + /// + /// The content unique identifier. + /// The content, or null. + /// Considers published or unpublished content depending on defaults. + Task GetByIdAsync(int id); + + /// + /// Gets a content identified by its unique identifier. + /// + /// The content unique identifier. + /// The content, or null. + /// Considers published or unpublished content depending on defaults. + Task GetByKeyAsync(Guid key); } diff --git a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs index 9987607f62a0..5b57236d4fbb 100644 --- a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs +++ b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs @@ -14,6 +14,12 @@ public InternalPublishedContentCache() { } + public Task GetByIdAsync(int id, bool preview = false) => throw new NotImplementedException(); + + public Task GetByIdAsync(Guid key, bool preview = false) => throw new NotImplementedException(); + + public Task HasByIdAsync(int id, bool preview = false) => throw new NotImplementedException(); + public IPublishedContent GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null) => throw new NotImplementedException(); public IPublishedContent GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null) => @@ -49,4 +55,9 @@ public override IEnumerable GetByContentType(IPublishedConten // public void Add(InternalPublishedContent content) => _content[content.Id] = content.CreateModel(Mock.Of()); public void Clear() => _content.Clear(); + public Task GetByIdAsync(int id) => throw new NotImplementedException(); + + public Task GetByKeyAsync(Guid key) => throw new NotImplementedException(); + + public Task HasByIdAsync(int id) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs index c79506fb5f3f..dc0fbf281d9e 100644 --- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs +++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs @@ -23,13 +23,8 @@ public sealed class HtmlLocalLinkParser private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - - public HtmlLocalLinkParser( - IUmbracoContextAccessor umbracoContextAccessor, - IPublishedUrlProvider publishedUrlProvider) + public HtmlLocalLinkParser(IPublishedUrlProvider publishedUrlProvider) { - _umbracoContextAccessor = umbracoContextAccessor; _publishedUrlProvider = publishedUrlProvider; } @@ -50,23 +45,7 @@ public HtmlLocalLinkParser( /// /// /// - public string EnsureInternalLinks(string text, bool preview) - { - if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)) - { - throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext"); - } - - if (!preview) - { - return EnsureInternalLinks(text); - } - - using (umbracoContext.ForcedPreview(preview)) // force for URL provider - { - return EnsureInternalLinks(text); - } - } + public string EnsureInternalLinks(string text, bool preview) => EnsureInternalLinks(text); /// /// Parses the string looking for the {localLink} syntax and updates them to their correct links. @@ -75,11 +54,6 @@ public string EnsureInternalLinks(string text, bool preview) /// public string EnsureInternalLinks(string text) { - if (!_umbracoContextAccessor.TryGetUmbracoContext(out _)) - { - throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext"); - } - foreach (LocalLinkTag tagData in FindLocalLinkIds(text)) { if (tagData.Udi is not null) diff --git a/src/Umbraco.Core/Web/IUmbracoContext.cs b/src/Umbraco.Core/Web/IUmbracoContext.cs index 17ffc515a22c..7c0bb311bfd3 100644 --- a/src/Umbraco.Core/Web/IUmbracoContext.cs +++ b/src/Umbraco.Core/Web/IUmbracoContext.cs @@ -31,6 +31,7 @@ public interface IUmbracoContext : IDisposable /// IPublishedSnapshot PublishedSnapshot { get; } + // TODO: Obsolete these, and use cache manager to get /// /// Gets the published content cache. /// diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs index 1dc12a805f49..bebf59cbe72d 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs @@ -120,6 +120,7 @@ public static IEnumerable QueryPaged(this IDatabase database, long pageSiz /// once T1 and T2 have completed. Whereas here, it could contain T1's value. /// /// + [Obsolete("Use InsertOrUpdateAsync instead")] public static RecordPersistenceType InsertOrUpdate(this IUmbracoDatabase db, T poco) where T : class => db.InsertOrUpdate(poco, null, null); @@ -150,7 +151,7 @@ public static RecordPersistenceType InsertOrUpdate(this IUmbracoDatabase db, /// once T1 and T2 have completed. Whereas here, it could contain T1's value. /// /// - public static RecordPersistenceType InsertOrUpdate( + public static async Task InsertOrUpdateAsync( this IUmbracoDatabase db, T poco, string? updateCommand, @@ -167,7 +168,7 @@ public static RecordPersistenceType InsertOrUpdate( // try to update var rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null - ? db.Update(poco) + ? await db.UpdateAsync(poco) : db.Update(updateCommand!, updateArgs); if (rowCount > 0) { @@ -182,7 +183,7 @@ public static RecordPersistenceType InsertOrUpdate( try { // try to insert - db.Insert(poco); + await db.InsertAsync(poco); return RecordPersistenceType.Insert; } catch (DbException) @@ -193,7 +194,7 @@ public static RecordPersistenceType InsertOrUpdate( // try to update rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null - ? db.Update(poco) + ? await db.UpdateAsync(poco) : db.Update(updateCommand!, updateArgs); if (rowCount > 0) { @@ -209,6 +210,14 @@ public static RecordPersistenceType InsertOrUpdate( throw new DataException("Record could not be inserted or updated."); } + public static RecordPersistenceType InsertOrUpdate( + this IUmbracoDatabase db, + T poco, + string? updateCommand, + object? updateArgs) + where T : class => + db.InsertOrUpdateAsync(poco, updateCommand, updateArgs).GetAwaiter().GetResult(); + /// /// This will escape single @ symbols for npoco values so it doesn't think it's a parameter /// diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index b90ceb9c2335..70c41277d08d 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -60,6 +60,21 @@ public static Sql WhereIn(this Sql sql, Expressi return sql; } + /// + /// Appends a WHERE IN clause to the Sql statement. + /// + /// The type of the Dto. + /// The Sql statement. + /// An expression specifying the field. + /// The values. + /// The Sql statement. + public static Sql WhereIn(this Sql sql, Expression> field, IEnumerable? values, string alias) + { + var fieldName = sql.SqlContext.SqlSyntax.GetFieldName(field, alias); + sql.Where(fieldName + " IN (@values)", new { values }); + return sql; + } + /// /// Appends a WHERE IN clause to the Sql statement. /// diff --git a/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs b/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs index f21635d2df47..74f27ba8dd9b 100644 --- a/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs +++ b/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs @@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.PublishedCache; /// Represents a content type cache. /// /// This cache is not snapshotted, so it refreshes any time things change. -public class PublishedContentTypeCache : IDisposable +public class PublishedContentTypeCache : IPublishedContentTypeCache { private readonly IContentTypeService? _contentTypeService; private readonly Dictionary _keyToIdMap = new(); @@ -23,11 +23,13 @@ public class PublishedContentTypeCache : IDisposable // NOTE: These are not concurrent dictionaries because all access is done within a lock private readonly Dictionary _typesByAlias = new(); private readonly Dictionary _typesById = new(); - private bool _disposedValue; // default ctor - public PublishedContentTypeCache(IContentTypeService? contentTypeService, IMediaTypeService? mediaTypeService, - IMemberTypeService? memberTypeService, IPublishedContentTypeFactory publishedContentTypeFactory, + public PublishedContentTypeCache( + IContentTypeService? contentTypeService, + IMediaTypeService? mediaTypeService, + IMemberTypeService? memberTypeService, + IPublishedContentTypeFactory publishedContentTypeFactory, ILogger logger) { _contentTypeService = contentTypeService; @@ -48,13 +50,6 @@ internal PublishedContentTypeCache( _publishedContentTypeFactory = publishedContentTypeFactory; } - public void Dispose() => - - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(true); - - // note: cache clearing is performed by XmlStore - /// /// Clears all cached content types. /// @@ -175,7 +170,10 @@ public IPublishedContentType Get(PublishedItemType itemType, Guid key) if (_keyToIdMap.TryGetValue(key, out var id)) { - return Get(itemType, id); + if (_typesById.TryGetValue(id, out IPublishedContentType? foundType)) + { + return foundType; + } } IPublishedContentType type = CreatePublishedContentType(itemType, key); @@ -289,19 +287,6 @@ public IPublishedContentType Get(PublishedItemType itemType, int id) } } - protected virtual void Dispose(bool disposing) - { - if (!_disposedValue) - { - if (disposing) - { - _lock.Dispose(); - } - - _disposedValue = true; - } - } - private static string GetAliasKey(PublishedItemType itemType, string alias) { string k; diff --git a/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs b/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs new file mode 100644 index 000000000000..28dfa6ee5839 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +/// +public class CacheManager : ICacheManager +{ + public CacheManager(IPublishedContentCache content, IPublishedMediaCache media, IPublishedMemberCache members, IDomainCache domains, IElementsCache elementsCache) + { + ElementsCache = elementsCache; + Content = content; + Media = media; + Members = members; + Domains = domains; + } + + public IPublishedContentCache Content { get; } + + public IPublishedMediaCache Media { get; } + + public IPublishedMemberCache Members { get; } + + public IDomainCache Domains { get; } + + public IAppCache ElementsCache { get; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs b/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs new file mode 100644 index 000000000000..e72d4f234bf9 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects +[ImmutableObject(true)] +internal sealed class ContentCacheNode +{ + public int Id { get; set; } + + public Guid Key { get; set; } + + public int SortOrder { get; set; } + + public DateTime CreateDate { get; set; } + + public int CreatorId { get; set; } + + public int ContentTypeId { get; set; } + + public bool IsDraft { get; set; } + + public ContentData? Data { get; set; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentData.cs b/src/Umbraco.PublishedCache.HybridCache/ContentData.cs new file mode 100644 index 000000000000..c314241479c8 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/ContentData.cs @@ -0,0 +1,45 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +/// +/// Represents everything that is specific to an edited or published content version +/// +// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects +[ImmutableObject(true)] +internal sealed class ContentData +{ + public ContentData(string? name, string? urlSegment, int versionId, DateTime versionDate, int writerId, int? templateId, bool published, Dictionary? properties, IReadOnlyDictionary? cultureInfos) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + UrlSegment = urlSegment; + VersionId = versionId; + VersionDate = versionDate; + WriterId = writerId; + TemplateId = templateId; + Published = published; + Properties = properties ?? throw new ArgumentNullException(nameof(properties)); + CultureInfos = cultureInfos; + } + + public string Name { get; } + + public string? UrlSegment { get; } + + public int VersionId { get; } + + public DateTime VersionDate { get; } + + public int WriterId { get; } + + public int? TemplateId { get; } + + public bool Published { get; } + + public Dictionary Properties { get; } + + /// + /// The collection of language Id to name for the content item + /// + public IReadOnlyDictionary? CultureInfos { get; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs b/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs new file mode 100644 index 000000000000..7db0b284baa9 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs @@ -0,0 +1,61 @@ +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +// represents a content "node" ie a pair of draft + published versions +// internal, never exposed, to be accessed from ContentStore (only!) +internal sealed class ContentNode +{ + // everything that is common to both draft and published versions + // keep this as small as possible +#pragma warning disable IDE1006 // Naming Styles + public readonly int Id; + + + // draft and published version (either can be null, but not both) + // are models not direct PublishedContent instances + private ContentData? _draftData; + private ContentData? _publishedData; + + public ContentNode( + int id, + Guid key, + int sortOrder, + DateTime createDate, + int creatorId, + IPublishedContentType contentType, + ContentData? draftData, + ContentData? publishedData) + { + Id = id; + Key = key; + SortOrder = sortOrder; + CreateDate = createDate; + CreatorId = creatorId; + ContentType = contentType; + + if (draftData == null && publishedData == null) + { + throw new ArgumentException("Both draftData and publishedData cannot be null at the same time."); + } + + _draftData = draftData; + _publishedData = publishedData; + } + + public bool HasPublished => _publishedData != null; + + public ContentData? DraftModel => _draftData; + + public ContentData? PublishedModel => _publishedData; + + public readonly Guid Key; + public IPublishedContentType ContentType; + public readonly int SortOrder; + public readonly DateTime CreateDate; + public readonly int CreatorId; + + public bool HasPublishedCulture(string culture) => _publishedData != null && (_publishedData.CultureInfos?.ContainsKey(culture) ?? false); +#pragma warning restore IDE1006 // Naming Styles +} diff --git a/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs b/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs new file mode 100644 index 000000000000..e8d74daa024b --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs @@ -0,0 +1,29 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +/// +/// Represents the culture variation information on a content item +/// +[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys +public class CultureVariation +{ + [DataMember(Order = 0)] + [JsonPropertyName("nm")] + public string? Name { get; set; } + + [DataMember(Order = 1)] + [JsonPropertyName("us")] + public string? UrlSegment { get; set; } + + [DataMember(Order = 2)] + [JsonPropertyName("dt")] + [JsonConverter(typeof(JsonUniversalDateTimeConverter))] + public DateTime Date { get; set; } + + [DataMember(Order = 3)] + [JsonPropertyName("isd")] + public bool IsDraft { get; set; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs new file mode 100644 index 000000000000..6ad695c154c9 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -0,0 +1,67 @@ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.HybridCache; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; +using Umbraco.Cms.Infrastructure.HybridCache.Persistence; +using Umbraco.Cms.Infrastructure.HybridCache.Serialization; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + + +namespace Umbraco.Extensions; + +/// +/// Extension methods for for the Umbraco's NuCache +/// +public static class UmbracoBuilderExtensions +{ + /// + /// Adds Umbraco NuCache dependencies + /// + public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder) + { + builder.Services.AddHybridCache(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(s => + { + IOptions options = s.GetRequiredService>(); + switch (options.Value.NuCacheSerializerType) + { + case NuCacheSerializerType.JSON: + return new JsonContentNestedDataSerializerFactory(); + case NuCacheSerializerType.MessagePack: + return ActivatorUtilities.CreateInstance(s); + default: + throw new IndexOutOfRangeException(); + } + }); + builder.Services.AddSingleton(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + return builder; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs new file mode 100644 index 000000000000..2723a281c2aa --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs @@ -0,0 +1,64 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +public sealed class DocumentCache : IPublishedContentCache +{ + private readonly IDocumentCacheService _documentCacheService; + private readonly IPublishedContentTypeCache _publishedContentTypeCache; + + public DocumentCache(IDocumentCacheService documentCacheService, IPublishedContentTypeCache publishedContentTypeCache) + { + _documentCacheService = documentCacheService; + _publishedContentTypeCache = publishedContentTypeCache; + } + + public async Task GetByIdAsync(int id, bool preview = false) => await _documentCacheService.GetByIdAsync(id, preview); + + + public async Task GetByIdAsync(Guid key, bool preview = false) => await _documentCacheService.GetByKeyAsync(key, preview); + + public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult(); + + public IPublishedContent? GetById(bool preview, Guid contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult(); + + + public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId, false).GetAwaiter().GetResult(); + + public IPublishedContent? GetById(Guid contentId) => GetByIdAsync(contentId, false).GetAwaiter().GetResult(); + + public IPublishedContentType? GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Content, id); + + public IPublishedContentType? GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Content, alias); + + + public IPublishedContentType? GetContentType(Guid key) => _publishedContentTypeCache.Get(PublishedItemType.Content, key); + + // FIXME: These need to be refactored when removing nucache + // Thats the time where we can change the IPublishedContentCache interface. + + public IPublishedContent? GetById(bool preview, Udi contentId) => throw new NotImplementedException(); + + public IPublishedContent? GetById(Udi contentId) => throw new NotImplementedException(); + + public IEnumerable GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException(); + + public IEnumerable GetAtRoot(string? culture = null) => throw new NotImplementedException(); + + public bool HasContent(bool preview) => throw new NotImplementedException(); + + public bool HasContent() => throw new NotImplementedException(); + + public IEnumerable GetByContentType(IPublishedContentType contentType) => throw new NotImplementedException(); + + public IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null) => throw new NotImplementedException(); + + public IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null) => throw new NotImplementedException(); + + public string? GetRouteById(bool preview, int contentId, string? culture = null) => throw new NotImplementedException(); + + public string? GetRouteById(int contentId, string? culture = null) => throw new NotImplementedException(); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs b/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs new file mode 100644 index 000000000000..8d07ef7dd7d3 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +/// +/// Implements for NuCache. +/// +public class DomainCache : IDomainCache +{ + private readonly IDomainCacheService _domainCacheService; + + /// + /// Initializes a new instance of the class. + /// + public DomainCache(IDefaultCultureAccessor defaultCultureAccessor, IDomainCacheService domainCacheService) + { + _domainCacheService = domainCacheService; + DefaultCulture = defaultCultureAccessor.DefaultCulture; + } + + /// + public string DefaultCulture { get; } + + /// + public IEnumerable GetAll(bool includeWildcards) => _domainCacheService.GetAll(includeWildcards); + + /// + public IEnumerable GetAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.GetAssigned(documentId, includeWildcards); + + /// + public bool HasAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.HasAssigned(documentId, includeWildcards); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs b/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs new file mode 100644 index 000000000000..6415629b3802 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Cache; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +public class ElementsDictionaryAppCache : FastDictionaryAppCache, IElementsCache +{ +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs new file mode 100644 index 000000000000..7fd91c4603e2 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs @@ -0,0 +1,120 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; + +internal class CacheNodeFactory : ICacheNodeFactory +{ + private readonly IShortStringHelper _shortStringHelper; + private readonly UrlSegmentProviderCollection _urlSegmentProviders; + + public CacheNodeFactory(IShortStringHelper shortStringHelper, UrlSegmentProviderCollection urlSegmentProviders) + { + _shortStringHelper = shortStringHelper; + _urlSegmentProviders = urlSegmentProviders; + } + + public ContentCacheNode ToContentCacheNode(IContent content, bool preview) + { + ContentData contentData = GetContentData(content, !preview, preview ? content.PublishTemplateId : content.TemplateId); + return new ContentCacheNode + { + Id = content.Id, + Key = content.Key, + SortOrder = content.SortOrder, + CreateDate = content.CreateDate, + CreatorId = content.CreatorId, + ContentTypeId = content.ContentTypeId, + Data = contentData, + IsDraft = preview, + }; + } + + public ContentCacheNode ToContentCacheNode(IMedia media) + { + ContentData contentData = GetContentData(media, false, null); + return new ContentCacheNode + { + Id = media.Id, + Key = media.Key, + SortOrder = media.SortOrder, + CreateDate = media.CreateDate, + CreatorId = media.CreatorId, + ContentTypeId = media.ContentTypeId, + Data = contentData, + IsDraft = false, + }; + } + + private ContentData GetContentData(IContentBase content, bool published, int? templateId) + { + var propertyData = new Dictionary(); + foreach (IProperty prop in content.Properties) + { + var pdatas = new List(); + foreach (IPropertyValue pvalue in prop.Values.OrderBy(x => x.Culture)) + { + // sanitize - properties should be ok but ... never knows + if (!prop.PropertyType.SupportsVariation(pvalue.Culture, pvalue.Segment)) + { + continue; + } + + // note: at service level, invariant is 'null', but here invariant becomes 'string.Empty' + var value = published ? pvalue.PublishedValue : pvalue.EditedValue; + if (value != null) + { + pdatas.Add(new PropertyData + { + Culture = pvalue.Culture ?? string.Empty, + Segment = pvalue.Segment ?? string.Empty, + Value = value, + }); + } + } + + propertyData[prop.Alias] = pdatas.ToArray(); + } + + var cultureData = new Dictionary(); + + // sanitize - names should be ok but ... never knows + if (content.ContentType.VariesByCulture()) + { + ContentCultureInfosCollection? infos = content is IContent document + ? published + ? document.PublishCultureInfos + : document.CultureInfos + : content.CultureInfos; + + // ReSharper disable once UseDeconstruction + if (infos is not null) + { + foreach (ContentCultureInfos cultureInfo in infos) + { + var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture); + cultureData[cultureInfo.Culture] = new CultureVariation + { + Name = cultureInfo.Name, + UrlSegment = + content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders, cultureInfo.Culture), + Date = content.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue, + IsDraft = cultureIsDraft, + }; + } + } + } + + return new ContentData( + content.Name, + null, + content.VersionId, + content.UpdateDate, + content.CreatorId, + templateId, + published, + propertyData, + cultureData); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs new file mode 100644 index 000000000000..f16ea2b16260 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; + +internal interface ICacheNodeFactory +{ + ContentCacheNode ToContentCacheNode(IContent content, bool preview); + ContentCacheNode ToContentCacheNode(IMedia media); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs new file mode 100644 index 000000000000..c5bfe4fe9efb --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; + +internal interface IPublishedContentFactory +{ + IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview); + IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode); + + IPublishedMember ToPublishedMember(IMember member); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs new file mode 100644 index 000000000000..1afc3635558b --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -0,0 +1,159 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; + +internal class PublishedContentFactory : IPublishedContentFactory +{ + private readonly IElementsCache _elementsCache; + private readonly IVariationContextAccessor _variationContextAccessor; + private readonly IPublishedContentTypeCache _publishedContentTypeCache; + + + public PublishedContentFactory( + IElementsCache elementsCache, + IVariationContextAccessor variationContextAccessor, + IPublishedContentTypeCache publishedContentTypeCache) + { + _elementsCache = elementsCache; + _variationContextAccessor = variationContextAccessor; + _publishedContentTypeCache = publishedContentTypeCache; + } + + public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) + { + IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); + var contentNode = new ContentNode( + contentCacheNode.Id, + contentCacheNode.Key, + contentCacheNode.SortOrder, + contentCacheNode.CreateDate, + contentCacheNode.CreatorId, + contentType, + preview ? contentCacheNode.Data : null, + preview ? null : contentCacheNode.Data); + + IPublishedContent? model = GetModel(contentNode, preview); + + if (preview) + { + return model ?? GetPublishedContentAsDraft(model); + } + + return model; + } + + public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) + { + IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); + var contentNode = new ContentNode( + contentCacheNode.Id, + contentCacheNode.Key, + contentCacheNode.SortOrder, + contentCacheNode.CreateDate, + contentCacheNode.CreatorId, + contentType, + null, + contentCacheNode.Data); + + return GetModel(contentNode, false); + } + + public IPublishedMember ToPublishedMember(IMember member) + { + IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId); + + // Members are only "mapped" never cached, so these default values are a bit wierd, but they are not used. + var contentData = new ContentData( + member.Name, + null, + 0, + member.UpdateDate, + member.CreatorId, + null, + true, + GetPropertyValues(contentType, member), + null); + + var contentNode = new ContentNode( + member.Id, + member.Key, + member.SortOrder, + member.UpdateDate, + member.CreatorId, + contentType, + null, + contentData); + return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); + } + + private Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) + { + var properties = member + .Properties + .ToDictionary( + x => x.Alias, + x => new[] { new PropertyData { Value = x.GetValue(), Culture = string.Empty, Segment = string.Empty } }, + StringComparer.OrdinalIgnoreCase); + + // Add member properties + AddIf(contentType, properties, nameof(IMember.Email), member.Email); + AddIf(contentType, properties, nameof(IMember.Username), member.Username); + AddIf(contentType, properties, nameof(IMember.Comments), member.Comments); + AddIf(contentType, properties, nameof(IMember.IsApproved), member.IsApproved); + AddIf(contentType, properties, nameof(IMember.IsLockedOut), member.IsLockedOut); + AddIf(contentType, properties, nameof(IMember.LastLockoutDate), member.LastLockoutDate); + AddIf(contentType, properties, nameof(IMember.CreateDate), member.CreateDate); + AddIf(contentType, properties, nameof(IMember.LastLoginDate), member.LastLoginDate); + AddIf(contentType, properties, nameof(IMember.LastPasswordChangeDate), member.LastPasswordChangeDate); + + return properties; + } + + private void AddIf(IPublishedContentType contentType, IDictionary properties, string alias, object? value) + { + IPublishedPropertyType? propertyType = contentType.GetPropertyType(alias); + if (propertyType == null || propertyType.IsUserProperty) + { + return; + } + + properties[alias] = new[] { new PropertyData { Value = value, Culture = string.Empty, Segment = string.Empty } }; + } + + private IPublishedContent? GetModel(ContentNode node, bool preview) + { + ContentData? contentData = preview ? node.DraftModel : node.PublishedModel; + return contentData == null + ? null + : new PublishedContent( + node, + preview, + _elementsCache, + _variationContextAccessor); + } + + + private IPublishedContent? GetPublishedContentAsDraft(IPublishedContent? content) => + content == null ? null : + // an object in the cache is either an IPublishedContentOrMedia, + // or a model inheriting from PublishedContentExtended - in which + // case we need to unwrap to get to the original IPublishedContentOrMedia. + UnwrapIPublishedContent(content); + + private PublishedContent UnwrapIPublishedContent(IPublishedContent content) + { + while (content is PublishedContentWrapped wrapped) + { + content = wrapped.Unwrap(); + } + + if (!(content is PublishedContent inner)) + { + throw new InvalidOperationException("Innermost content is not PublishedContent."); + } + + return inner; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs b/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs new file mode 100644 index 000000000000..873a128d5336 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Cache; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +public interface IElementsCache : IAppCache +{ +} diff --git a/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs b/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs new file mode 100644 index 000000000000..53d59da72c04 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs @@ -0,0 +1,56 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +public class MediaCache : IPublishedMediaCache +{ + private readonly IMediaCacheService _mediaCacheService; + private readonly IPublishedContentTypeCache _publishedContentTypeCache; + + public MediaCache(IMediaCacheService mediaCacheService, IPublishedContentTypeCache publishedContentTypeCache) + { + _mediaCacheService = mediaCacheService; + _publishedContentTypeCache = publishedContentTypeCache; + } + + public async Task GetByIdAsync(int id) => await _mediaCacheService.GetByIdAsync(id); + + public async Task GetByKeyAsync(Guid key) => await _mediaCacheService.GetByKeyAsync(key); + + public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult(); + + public IPublishedContent? GetById(bool preview, Guid contentId) => + GetByKeyAsync(contentId).GetAwaiter().GetResult(); + + + public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult(); + + public IPublishedContent? GetById(Guid contentId) => GetByKeyAsync(contentId).GetAwaiter().GetResult(); + + + public IPublishedContentType? GetContentType(Guid key) => _publishedContentTypeCache.Get(PublishedItemType.Media, key); + + public IPublishedContentType GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Media, id); + + public IPublishedContentType GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Media, alias); + + // FIXME - these need to be removed when removing nucache + public IPublishedContent? GetById(bool preview, Udi contentId) => throw new NotImplementedException(); + + public IPublishedContent? GetById(Udi contentId) => throw new NotImplementedException(); + + public IEnumerable GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException(); + + public IEnumerable GetAtRoot(string? culture = null) => throw new NotImplementedException(); + + public bool HasContent(bool preview) => throw new NotImplementedException(); + + public bool HasContent() => throw new NotImplementedException(); + + + public IEnumerable GetByContentType(IPublishedContentType contentType) => + throw new NotImplementedException(); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs b/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs new file mode 100644 index 000000000000..e5029d16e6c9 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +public class MemberCache : IPublishedMemberCache +{ + private readonly IMemberCacheService _memberCacheService; + private readonly IPublishedContentTypeCache _publishedContentTypeCache; + + public MemberCache(IMemberCacheService memberCacheService, IPublishedContentTypeCache publishedContentTypeCache) + { + _memberCacheService = memberCacheService; + _publishedContentTypeCache = publishedContentTypeCache; + } + + public async Task GetAsync(IMember member) => + await _memberCacheService.Get(member); + + public IPublishedMember? Get(IMember member) => GetAsync(member).GetAwaiter().GetResult(); + + public IPublishedContentType GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Member, id); + + public IPublishedContentType GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Member, alias); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs new file mode 100644 index 000000000000..105fad1d9de0 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs @@ -0,0 +1,124 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Cms.Infrastructure.HybridCache.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; + +internal sealed class CacheRefreshingNotificationHandler : + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler +{ + private readonly IDocumentCacheService _documentCacheService; + private readonly IMediaCacheService _mediaCacheService; + private readonly IElementsCache _elementsCache; + private readonly IRelationService _relationService; + private readonly IPublishedContentTypeCache _publishedContentTypeCache; + + public CacheRefreshingNotificationHandler( + IDocumentCacheService documentCacheService, + IMediaCacheService mediaCacheService, + IElementsCache elementsCache, + IRelationService relationService, + IPublishedContentTypeCache publishedContentTypeCache) + { + _documentCacheService = documentCacheService; + _mediaCacheService = mediaCacheService; + _elementsCache = elementsCache; + _relationService = relationService; + _publishedContentTypeCache = publishedContentTypeCache; + } + + public async Task HandleAsync(ContentRefreshNotification notification, CancellationToken cancellationToken) + { + await RefreshElementsCacheAsync(notification.Entity); + + await _documentCacheService.RefreshContentAsync(notification.Entity); + } + + public async Task HandleAsync(ContentDeletedNotification notification, CancellationToken cancellationToken) + { + foreach (IContent deletedEntity in notification.DeletedEntities) + { + await RefreshElementsCacheAsync(deletedEntity); + await _documentCacheService.DeleteItemAsync(deletedEntity.Id); + } + } + + public async Task HandleAsync(MediaRefreshNotification notification, CancellationToken cancellationToken) + { + await RefreshElementsCacheAsync(notification.Entity); + await _mediaCacheService.RefreshMediaAsync(notification.Entity); + } + + public async Task HandleAsync(MediaDeletedNotification notification, CancellationToken cancellationToken) + { + foreach (IMedia deletedEntity in notification.DeletedEntities) + { + await RefreshElementsCacheAsync(deletedEntity); + await _mediaCacheService.DeleteItemAsync(deletedEntity.Id); + } + } + + private async Task RefreshElementsCacheAsync(IUmbracoEntity content) + { + IEnumerable parentRelations = _relationService.GetByParent(content)!; + IEnumerable childRelations = _relationService.GetByChild(content); + + var ids = parentRelations.Select(x => x.ChildId).Concat(childRelations.Select(x => x.ParentId)).ToHashSet(); + foreach (var id in ids) + { + if (await _documentCacheService.HasContentByIdAsync(id) is false) + { + continue; + } + + IPublishedContent? publishedContent = await _documentCacheService.GetByIdAsync(id); + if (publishedContent is null) + { + continue; + } + + foreach (IPublishedProperty publishedProperty in publishedContent.Properties) + { + var property = (PublishedProperty) publishedProperty; + if (property.ReferenceCacheLevel != PropertyCacheLevel.Elements) + { + continue; + } + + _elementsCache.ClearByKey(property.ValuesCacheKey); + } + } + } + + public Task HandleAsync(ContentTypeRefreshedNotification notification, CancellationToken cancellationToken) + { + const ContentTypeChangeTypes types // only for those that have been refreshed + = ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.RefreshOther | ContentTypeChangeTypes.Remove; + var contentTypeIds = notification.Changes.Where(x => x.ChangeTypes.HasTypesAny(types)).Select(x => x.Item.Id) + .ToArray(); + + if (contentTypeIds.Length != 0) + { + foreach (var contentTypeId in contentTypeIds) + { + _publishedContentTypeCache.ClearContentType(contentTypeId); + } + + _documentCacheService.Rebuild(contentTypeIds); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs new file mode 100644 index 000000000000..d0dfa76b678d --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Infrastructure.HybridCache.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; + +internal class SeedingNotificationHandler : INotificationAsyncHandler +{ + private readonly IDocumentCacheService _documentCacheService; + private readonly CacheSettings _cacheSettings; + + public SeedingNotificationHandler(IDocumentCacheService documentCacheService, IOptions cacheSettings) + { + _documentCacheService = documentCacheService; + _cacheSettings = cacheSettings.Value; + } + + public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) => await _documentCacheService.SeedAsync(_cacheSettings.ContentTypeKeys); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs new file mode 100644 index 000000000000..4d4fcae73d24 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs @@ -0,0 +1,67 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence +{ + // read-only dto + internal class ContentSourceDto : IReadOnlyContentBase + { + public int Id { get; init; } + + public Guid Key { get; init; } + + public int ContentTypeId { get; init; } + + public int Level { get; init; } + + public string Path { get; init; } = string.Empty; + + public int SortOrder { get; init; } + + public int ParentId { get; init; } + + public bool Published { get; init; } + + public bool Edited { get; init; } + + public DateTime CreateDate { get; init; } + + public int CreatorId { get; init; } + + // edited data + public int VersionId { get; init; } + + public string? EditName { get; init; } + + public DateTime EditVersionDate { get; init; } + + public int EditWriterId { get; init; } + + public int EditTemplateId { get; init; } + + public string? EditData { get; init; } + + public byte[]? EditDataRaw { get; init; } + + // published data + public int PublishedVersionId { get; init; } + + public string? PubName { get; init; } + + public DateTime PubVersionDate { get; init; } + + public int PubWriterId { get; init; } + + public int PubTemplateId { get; init; } + + public string? PubData { get; init; } + + public byte[]? PubDataRaw { get; init; } + + // Explicit implementation + DateTime IReadOnlyContentBase.UpdateDate => EditVersionDate; + + string? IReadOnlyContentBase.Name => EditName; + + int IReadOnlyContentBase.WriterId => EditWriterId; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs new file mode 100644 index 000000000000..d49d2f879905 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs @@ -0,0 +1,895 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.HybridCache.Serialization; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; +using static Umbraco.Cms.Core.Persistence.SqlExtensionsStatics; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence; + +internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRepository +{ + private readonly IContentCacheDataSerializerFactory _contentCacheDataSerializerFactory; + private readonly IDocumentRepository _documentRepository; + private readonly ILogger _logger; + private readonly IMediaRepository _mediaRepository; + private readonly IMemberRepository _memberRepository; + private readonly IOptions _nucacheSettings; + private readonly IShortStringHelper _shortStringHelper; + private readonly UrlSegmentProviderCollection _urlSegmentProviders; + + /// + /// Initializes a new instance of the class. + /// + public DatabaseCacheRepository( + IScopeAccessor scopeAccessor, + AppCaches appCaches, + ILogger logger, + IMemberRepository memberRepository, + IDocumentRepository documentRepository, + IMediaRepository mediaRepository, + IShortStringHelper shortStringHelper, + UrlSegmentProviderCollection urlSegmentProviders, + IContentCacheDataSerializerFactory contentCacheDataSerializerFactory, + IOptions nucacheSettings) + : base(scopeAccessor, appCaches) + { + _logger = logger; + _memberRepository = memberRepository; + _documentRepository = documentRepository; + _mediaRepository = mediaRepository; + _shortStringHelper = shortStringHelper; + _urlSegmentProviders = urlSegmentProviders; + _contentCacheDataSerializerFactory = contentCacheDataSerializerFactory; + _nucacheSettings = nucacheSettings; + } + + public async Task DeleteContentItemAsync(int id) + => await Database.ExecuteAsync("DELETE FROM cmsContentNu WHERE nodeId=@id", new { id = id }); + + public async Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState) + { + IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); + + // always refresh the edited data + await OnRepositoryRefreshed(serializer, contentCacheNode, true); + + switch (publishedState) + { + case PublishedState.Publishing: + await OnRepositoryRefreshed(serializer, contentCacheNode, false); + break; + case PublishedState.Unpublishing: + await Database.ExecuteAsync("DELETE FROM cmsContentNu WHERE nodeId=@id AND published=1", new { id = contentCacheNode.Id }); + break; + } + } + + public async Task RefreshMediaAsync(ContentCacheNode contentCacheNode) + { + IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); + await OnRepositoryRefreshed(serializer, contentCacheNode, false); + } + + /// + public void Rebuild( + IReadOnlyCollection? contentTypeIds = null, + IReadOnlyCollection? mediaTypeIds = null, + IReadOnlyCollection? memberTypeIds = null) + { + IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create( + ContentCacheDataSerializerEntityType.Document + | ContentCacheDataSerializerEntityType.Media + | ContentCacheDataSerializerEntityType.Member); + + // If contentTypeIds, mediaTypeIds and memberTypeIds are null, truncate table as all records will be deleted (as these 3 are the only types in the table). + if (contentTypeIds != null && !contentTypeIds.Any() + && mediaTypeIds != null && !mediaTypeIds.Any() + && memberTypeIds != null && !memberTypeIds.Any()) + { + if (Database.DatabaseType == DatabaseType.SqlServer2012) + { + Database.Execute($"TRUNCATE TABLE cmsContentNu"); + } + + if (Database.DatabaseType == DatabaseType.SQLite) + { + Database.Execute($"DELETE FROM cmsContentNu"); + } + } + + if (contentTypeIds != null) + { + RebuildContentDbCache(serializer, _nucacheSettings.Value.SqlPageSize, contentTypeIds); + } + + if (mediaTypeIds != null) + { + RebuildMediaDbCache(serializer, _nucacheSettings.Value.SqlPageSize, mediaTypeIds); + } + + if (memberTypeIds != null) + { + RebuildMemberDbCache(serializer, _nucacheSettings.Value.SqlPageSize, memberTypeIds); + } + } + + // assumes content tree lock + public bool VerifyContentDbCache() + { + // every document should have a corresponding row for edited properties + // and if published, may have a corresponding row for published properties + Guid contentObjectType = Constants.ObjectTypes.Document; + + var count = Database.ExecuteScalar( + $@"SELECT COUNT(*) +FROM umbracoNode +JOIN {Constants.DatabaseSchema.Tables.Document} ON umbracoNode.id={Constants.DatabaseSchema.Tables.Document}.nodeId +LEFT JOIN cmsContentNu nuEdited ON (umbracoNode.id=nuEdited.nodeId AND nuEdited.published=0) +LEFT JOIN cmsContentNu nuPublished ON (umbracoNode.id=nuPublished.nodeId AND nuPublished.published=1) +WHERE umbracoNode.nodeObjectType=@objType +AND nuEdited.nodeId IS NULL OR ({Constants.DatabaseSchema.Tables.Document}.published=1 AND nuPublished.nodeId IS NULL);", + new { objType = contentObjectType }); + + return count == 0; + } + + // assumes media tree lock + public bool VerifyMediaDbCache() + { + // every media item should have a corresponding row for edited properties + Guid mediaObjectType = Constants.ObjectTypes.Media; + + var count = Database.ExecuteScalar( + @"SELECT COUNT(*) +FROM umbracoNode +LEFT JOIN cmsContentNu ON (umbracoNode.id=cmsContentNu.nodeId AND cmsContentNu.published=0) +WHERE umbracoNode.nodeObjectType=@objType +AND cmsContentNu.nodeId IS NULL +", + new { objType = mediaObjectType }); + + return count == 0; + } + + // assumes member tree lock + public bool VerifyMemberDbCache() + { + // every member item should have a corresponding row for edited properties + Guid memberObjectType = Constants.ObjectTypes.Member; + + var count = Database.ExecuteScalar( + @"SELECT COUNT(*) +FROM umbracoNode +LEFT JOIN cmsContentNu ON (umbracoNode.id=cmsContentNu.nodeId AND cmsContentNu.published=0) +WHERE umbracoNode.nodeObjectType=@objType +AND cmsContentNu.nodeId IS NULL +", + new { objType = memberObjectType }); + + return count == 0; + } + + public async Task GetContentSourceAsync(int id, bool preview = false) + { + Sql? sql = SqlContentSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) + .Append(SqlWhereNodeId(SqlContext, id)) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + ContentSourceDto? dto = await Database.FirstOrDefaultAsync(sql); + + if (dto == null) + { + return null; + } + + if (preview is false && dto.PubDataRaw is null && dto.PubData is null) + { + return null; + } + + IContentCacheDataSerializer serializer = + _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); + return CreateContentNodeKit(dto, serializer, preview); + } + + public IEnumerable GetContentByContentTypeKey(IEnumerable keys) + { + if (keys.Any() is false) + { + yield break; + } + + Sql? sql = SqlContentSourcesSelect() + .InnerJoin("n") + .On((n, c) => n.NodeId == c.ContentTypeId, "n", "umbracoContent") + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) + .WhereIn(x => x.UniqueId, keys,"n") + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + IContentCacheDataSerializer serializer = + _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); + + IEnumerable dtos = GetContentNodeDtos(sql); + + foreach (ContentSourceDto row in dtos) + { + yield return CreateContentNodeKit(row, serializer, row.Published is false); + } + } + + public async Task GetMediaSourceAsync(int id) + { + Sql? sql = SqlMediaSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media)) + .Append(SqlWhereNodeId(SqlContext, id)) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + ContentSourceDto? dto = await Database.FirstOrDefaultAsync(sql); + + if (dto is null) + { + return null; + } + + IContentCacheDataSerializer serializer = + _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); + return CreateMediaNodeKit(dto, serializer); + } + + private async Task OnRepositoryRefreshed(IContentCacheDataSerializer serializer, ContentCacheNode content, bool preview) + { + // use a custom SQL to update row version on each update + // db.InsertOrUpdate(dto); + ContentNuDto dto = GetDtoFromCacheNode(content, !preview, serializer); + + await Database.InsertOrUpdateAsync( + dto, + "SET data=@data, dataRaw=@dataRaw, rv=rv+1 WHERE nodeId=@id AND published=@published", + new + { + dataRaw = dto.RawData ?? Array.Empty(), + data = dto.Data, + id = dto.NodeId, + published = dto.Published, + }); + } + + // assumes content tree lock + private void RebuildContentDbCache(IContentCacheDataSerializer serializer, int groupSize, IReadOnlyCollection? contentTypeIds) + { + Guid contentObjectType = Constants.ObjectTypes.Document; + + // remove all - if anything fails the transaction will rollback + if (contentTypeIds == null || contentTypeIds.Count == 0) + { + // must support SQL-CE + Database.Execute( + @"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType +)", + new { objType = contentObjectType }); + } + else + { + // assume number of ctypes won't blow IN(...) + // must support SQL-CE + Database.Execute( + $@"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode + JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id + WHERE umbracoNode.nodeObjectType=@objType + AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes) +)", + new { objType = contentObjectType, ctypes = contentTypeIds }); + } + + // insert back - if anything fails the transaction will rollback + IQuery query = SqlContext.Query(); + if (contentTypeIds != null && contentTypeIds.Count > 0) + { + query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...) + } + + long pageIndex = 0; + long processed = 0; + long total; + do + { + // the tree is locked, counting and comparing to total is safe + IEnumerable descendants = + _documentRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path")); + var items = new List(); + var count = 0; + foreach (IContent c in descendants) + { + // always the edited version + items.Add(GetDtoFromContent(c, false, serializer)); + + // and also the published version if it makes any sense + if (c.Published) + { + items.Add(GetDtoFromContent(c, true, serializer)); + } + + count++; + } + + Database.BulkInsertRecords(items); + processed += count; + } while (processed < total); + } + + // assumes media tree lock + private void RebuildMediaDbCache(IContentCacheDataSerializer serializer, int groupSize, + IReadOnlyCollection? contentTypeIds) + { + Guid mediaObjectType = Constants.ObjectTypes.Media; + + // remove all - if anything fails the transaction will rollback + if (contentTypeIds is null || contentTypeIds.Count == 0) + { + // must support SQL-CE + Database.Execute( + @"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType +)", + new { objType = mediaObjectType }); + } + else + { + // assume number of ctypes won't blow IN(...) + // must support SQL-CE + Database.Execute( + $@"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode + JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id + WHERE umbracoNode.nodeObjectType=@objType + AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes) +)", + new { objType = mediaObjectType, ctypes = contentTypeIds }); + } + + // insert back - if anything fails the transaction will rollback + IQuery query = SqlContext.Query(); + if (contentTypeIds is not null && contentTypeIds.Count > 0) + { + query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...) + } + + long pageIndex = 0; + long processed = 0; + long total; + do + { + // the tree is locked, counting and comparing to total is safe + IEnumerable descendants = + _mediaRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path")); + var items = descendants.Select(m => GetDtoFromContent(m, false, serializer)).ToArray(); + Database.BulkInsertRecords(items); + processed += items.Length; + } while (processed < total); + } + + // assumes member tree lock + private void RebuildMemberDbCache(IContentCacheDataSerializer serializer, int groupSize, + IReadOnlyCollection? contentTypeIds) + { + Guid memberObjectType = Constants.ObjectTypes.Member; + + // remove all - if anything fails the transaction will rollback + if (contentTypeIds == null || contentTypeIds.Count == 0) + { + // must support SQL-CE + Database.Execute( + @"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType +)", + new { objType = memberObjectType }); + } + else + { + // assume number of ctypes won't blow IN(...) + // must support SQL-CE + Database.Execute( + $@"DELETE FROM cmsContentNu +WHERE cmsContentNu.nodeId IN ( + SELECT id FROM umbracoNode + JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id + WHERE umbracoNode.nodeObjectType=@objType + AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes) +)", + new { objType = memberObjectType, ctypes = contentTypeIds }); + } + + // insert back - if anything fails the transaction will rollback + IQuery query = SqlContext.Query(); + if (contentTypeIds != null && contentTypeIds.Count > 0) + { + query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...) + } + + long pageIndex = 0; + long processed = 0; + long total; + do + { + IEnumerable descendants = + _memberRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path")); + ContentNuDto[] items = descendants.Select(m => GetDtoFromContent(m, false, serializer)).ToArray(); + Database.BulkInsertRecords(items); + processed += items.Length; + } while (processed < total); + } + + private ContentNuDto GetDtoFromCacheNode(ContentCacheNode cacheNode, bool published, IContentCacheDataSerializer serializer) + { + // the dictionary that will be serialized + var contentCacheData = new ContentCacheDataModel + { + PropertyData = cacheNode.Data?.Properties, + CultureData = cacheNode.Data?.CultureInfos?.ToDictionary(), + UrlSegment = cacheNode.Data?.UrlSegment, + }; + + // TODO: We should probably fix all serialization to only take ContentTypeId, for now it takes an IReadOnlyContentBase + // but it is only the content type id that is needed. + ContentCacheDataSerializationResult serialized = serializer.Serialize(new ContentSourceDto { ContentTypeId = cacheNode.ContentTypeId, }, contentCacheData, published); + + var dto = new ContentNuDto + { + NodeId = cacheNode.Id, Published = published, Data = serialized.StringData, RawData = serialized.ByteData, + }; + + return dto; + } + + private ContentNuDto GetDtoFromContent(IContentBase content, bool published, IContentCacheDataSerializer serializer) + { + // should inject these in ctor + // BUT for the time being we decide not to support ConvertDbToXml/String + // var propertyEditorResolver = PropertyEditorResolver.Current; + // var dataTypeService = ApplicationContext.Current.Services.DataTypeService; + var propertyData = new Dictionary(); + foreach (IProperty prop in content.Properties) + { + var pdatas = new List(); + foreach (IPropertyValue pvalue in prop.Values.OrderBy(x => x.Culture)) + { + // sanitize - properties should be ok but ... never knows + if (!prop.PropertyType.SupportsVariation(pvalue.Culture, pvalue.Segment)) + { + continue; + } + + // note: at service level, invariant is 'null', but here invariant becomes 'string.Empty' + var value = published ? pvalue.PublishedValue : pvalue.EditedValue; + if (value != null) + { + pdatas.Add(new PropertyData + { + Culture = pvalue.Culture ?? string.Empty, + Segment = pvalue.Segment ?? string.Empty, + Value = value, + }); + } + } + + propertyData[prop.Alias] = pdatas.ToArray(); + } + + var cultureData = new Dictionary(); + + // sanitize - names should be ok but ... never knows + if (content.ContentType.VariesByCulture()) + { + ContentCultureInfosCollection? infos = content is IContent document + ? published + ? document.PublishCultureInfos + : document.CultureInfos + : content.CultureInfos; + + // ReSharper disable once UseDeconstruction + if (infos is not null) + { + foreach (ContentCultureInfos cultureInfo in infos) + { + var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture); + cultureData[cultureInfo.Culture] = new CultureVariation + { + Name = cultureInfo.Name, + UrlSegment = + content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders, cultureInfo.Culture), + Date = content.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue, + IsDraft = cultureIsDraft, + }; + } + } + } + + // the dictionary that will be serialized + var contentCacheData = new ContentCacheDataModel + { + PropertyData = propertyData, + CultureData = cultureData, + UrlSegment = content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders), + }; + + ContentCacheDataSerializationResult serialized = + serializer.Serialize(ReadOnlyContentBaseAdapter.Create(content), contentCacheData, published); + + var dto = new ContentNuDto + { + NodeId = content.Id, Published = published, Data = serialized.StringData, RawData = serialized.ByteData, + }; + + return dto; + } + + // we want arrays, we want them all loaded, not an enumerable + private Sql SqlContentSourcesSelect(Func>? joins = null) + { + SqlTemplate sqlTemplate = SqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesSelect, + tsql => + tsql.Select( + x => Alias(x.NodeId, "Id"), + x => Alias(x.UniqueId, "Key"), + x => Alias(x.Level, "Level"), + x => Alias(x.Path, "Path"), + x => Alias(x.SortOrder, "SortOrder"), + x => Alias(x.ParentId, "ParentId"), + x => Alias(x.CreateDate, "CreateDate"), + x => Alias(x.UserId, "CreatorId")) + .AndSelect(x => Alias(x.ContentTypeId, "ContentTypeId")) + .AndSelect(x => Alias(x.Published, "Published"), x => Alias(x.Edited, "Edited")) + .AndSelect( + x => Alias(x.Id, "VersionId"), + x => Alias(x.Text, "EditName"), + x => Alias(x.VersionDate, "EditVersionDate"), + x => Alias(x.UserId, "EditWriterId")) + .AndSelect(x => Alias(x.TemplateId, "EditTemplateId")) + .AndSelect( + "pcver", + x => Alias(x.Id, "PublishedVersionId"), + x => Alias(x.Text, "PubName"), + x => Alias(x.VersionDate, "PubVersionDate"), + x => Alias(x.UserId, "PubWriterId")) + .AndSelect("pdver", x => Alias(x.TemplateId, "PubTemplateId")) + .AndSelect("nuEdit", x => Alias(x.Data, "EditData")) + .AndSelect("nuPub", x => Alias(x.Data, "PubData")) + .AndSelect("nuEdit", x => Alias(x.RawData, "EditDataRaw")) + .AndSelect("nuPub", x => Alias(x.RawData, "PubDataRaw")) + .From()); + + Sql? sql = sqlTemplate.Sql(); + + // TODO: I'm unsure how we can format the below into SQL templates also because right.Current and right.Published end up being parameters + if (joins != null) + { + sql = sql.Append(joins(sql.SqlContext)); + } + + sql = sql + .InnerJoin().On((left, right) => left.NodeId == right.NodeId) + .InnerJoin().On((left, right) => left.NodeId == right.NodeId) + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId && right.Current) + .InnerJoin() + .On((left, right) => left.Id == right.Id) + .LeftJoin( + j => + j.InnerJoin("pdver") + .On( + (left, right) => left.Id == right.Id && right.Published == true, "pcver", "pdver"), + "pcver") + .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver") + .LeftJoin("nuEdit").On( + (left, right) => left.NodeId == right.NodeId && right.Published == false, aliasRight: "nuEdit") + .LeftJoin("nuPub").On( + (left, right) => left.NodeId == right.NodeId && right.Published == true, aliasRight: "nuPub"); + + return sql; + } + + private Sql SqlContentSourcesSelectUmbracoNodeJoin(ISqlContext sqlContext) + { + ISqlSyntaxProvider syntax = sqlContext.SqlSyntax; + + SqlTemplate sqlTemplate = sqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.SourcesSelectUmbracoNodeJoin, builder => + builder.InnerJoin("x") + .On( + (left, right) => left.NodeId == right.NodeId || + SqlText(left.Path, right.Path, + (lp, rp) => $"({lp} LIKE {syntax.GetConcat(rp, "',%'")})"), + aliasRight: "x")); + + Sql sql = sqlTemplate.Sql(); + return sql; + } + + private Sql SqlWhereNodeId(ISqlContext sqlContext, int id) + { + ISqlSyntaxProvider syntax = sqlContext.SqlSyntax; + + SqlTemplate sqlTemplate = sqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.WhereNodeId, + builder => + builder.Where(x => x.NodeId == SqlTemplate.Arg("id"))); + + Sql sql = sqlTemplate.Sql(id); + return sql; + } + + private Sql SqlOrderByLevelIdSortOrder(ISqlContext sqlContext) + { + ISqlSyntaxProvider syntax = sqlContext.SqlSyntax; + + SqlTemplate sqlTemplate = sqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.OrderByLevelIdSortOrder, s => + s.OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder)); + + Sql sql = sqlTemplate.Sql(); + return sql; + } + + private Sql SqlObjectTypeNotTrashed(ISqlContext sqlContext, Guid nodeObjectType) + { + ISqlSyntaxProvider syntax = sqlContext.SqlSyntax; + + SqlTemplate sqlTemplate = sqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.ObjectTypeNotTrashedFilter, s => + s.Where(x => + x.NodeObjectType == SqlTemplate.Arg("nodeObjectType") && + x.Trashed == SqlTemplate.Arg("trashed"))); + + Sql sql = sqlTemplate.Sql(nodeObjectType, false); + return sql; + } + + /// + /// Returns a slightly more optimized query to use for the document counting when paging over the content sources + /// + /// + /// + private Sql SqlContentSourcesCount(Func>? joins = null) + { + SqlTemplate sqlTemplate = SqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesCount, tsql => + tsql.Select(x => Alias(x.NodeId, "Id")) + .From() + .InnerJoin().On((left, right) => left.NodeId == right.NodeId) + .InnerJoin().On((left, right) => left.NodeId == right.NodeId)); + + Sql? sql = sqlTemplate.Sql(); + + if (joins != null) + { + sql = sql.Append(joins(sql.SqlContext)); + } + + // TODO: We can't use a template with this one because of the 'right.Current' and 'right.Published' ends up being a parameter so not sure how we can do that + sql = sql + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId && right.Current) + .InnerJoin() + .On((left, right) => left.Id == right.Id) + .LeftJoin( + j => + j.InnerJoin("pdver") + .On( + (left, right) => left.Id == right.Id && right.Published, + "pcver", + "pdver"), + "pcver") + .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver"); + + return sql; + } + + private Sql SqlMediaSourcesSelect(Func>? joins = null) + { + SqlTemplate sqlTemplate = SqlContext.Templates.Get( + Constants.SqlTemplates.NuCacheDatabaseDataSource.MediaSourcesSelect, tsql => + tsql.Select( + x => Alias(x.NodeId, "Id"), + x => Alias(x.UniqueId, "Key"), + x => Alias(x.Level, "Level"), + x => Alias(x.Path, "Path"), + x => Alias(x.SortOrder, "SortOrder"), + x => Alias(x.ParentId, "ParentId"), + x => Alias(x.CreateDate, "CreateDate"), + x => Alias(x.UserId, "CreatorId")) + .AndSelect(x => Alias(x.ContentTypeId, "ContentTypeId")) + .AndSelect( + x => Alias(x.Id, "VersionId"), + x => Alias(x.Text, "EditName"), + x => Alias(x.VersionDate, "EditVersionDate"), + x => Alias(x.UserId, "EditWriterId")) + .AndSelect("nuEdit", x => Alias(x.Data, "EditData")) + .AndSelect("nuEdit", x => Alias(x.RawData, "EditDataRaw")) + .From()); + + Sql? sql = sqlTemplate.Sql(); + + if (joins != null) + { + sql = sql.Append(joins(sql.SqlContext)); + } + + // TODO: We can't use a template with this one because of the 'right.Published' ends up being a parameter so not sure how we can do that + sql = sql + .InnerJoin().On((left, right) => left.NodeId == right.NodeId) + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId && right.Current) + .LeftJoin("nuEdit") + .On( + (left, right) => left.NodeId == right.NodeId && !right.Published, + aliasRight: "nuEdit"); + + return sql; + } + + private ContentCacheNode CreateContentNodeKit(ContentSourceDto dto, IContentCacheDataSerializer serializer, bool preview) + { + if (preview) + { + if (dto.EditData == null && dto.EditDataRaw == null) + { + if (Debugger.IsAttached) + { + throw new InvalidOperationException("Missing cmsContentNu edited content for node " + dto.Id + + ", consider rebuilding."); + } + + _logger.LogWarning( + "Missing cmsContentNu edited content for node {NodeId}, consider rebuilding.", + dto.Id); + } + else + { + bool published = false; + ContentCacheDataModel? deserializedDraftContent = + serializer.Deserialize(dto, dto.EditData, dto.EditDataRaw, published); + var draftContentData = new ContentData( + dto.EditName, + null, + dto.VersionId, + dto.EditVersionDate, + dto.CreatorId, + dto.EditTemplateId == 0 ? null : dto.EditTemplateId, + published, + deserializedDraftContent?.PropertyData, + deserializedDraftContent?.CultureData); + + return new ContentCacheNode + { + Id = dto.Id, + Key = dto.Key, + SortOrder = dto.SortOrder, + CreateDate = dto.CreateDate, + CreatorId = dto.CreatorId, + ContentTypeId = dto.ContentTypeId, + Data = draftContentData, + IsDraft = true, + }; + } + } + + if (dto.PubData == null && dto.PubDataRaw == null) + { + if (Debugger.IsAttached) + { + throw new InvalidOperationException("Missing cmsContentNu published content for node " + dto.Id + + ", consider rebuilding."); + } + + _logger.LogWarning( + "Missing cmsContentNu published content for node {NodeId}, consider rebuilding.", + dto.Id); + } + + ContentCacheDataModel? deserializedContent = serializer.Deserialize(dto, dto.PubData, dto.PubDataRaw, true); + var publishedContentData = new ContentData( + dto.PubName, + null, + dto.VersionId, + dto.PubVersionDate, + dto.CreatorId, + dto.EditTemplateId == 0 ? null : dto.EditTemplateId, + true, + deserializedContent?.PropertyData, + deserializedContent?.CultureData); + + return new ContentCacheNode + { + Id = dto.Id, + Key = dto.Key, + SortOrder = dto.SortOrder, + CreateDate = dto.CreateDate, + CreatorId = dto.CreatorId, + ContentTypeId = dto.ContentTypeId, + Data = publishedContentData, + IsDraft = false, + }; + } + + private ContentCacheNode CreateMediaNodeKit(ContentSourceDto dto, IContentCacheDataSerializer serializer) + { + if (dto.EditData == null && dto.EditDataRaw == null) + { + throw new InvalidOperationException("No data for media " + dto.Id); + } + + ContentCacheDataModel? deserializedMedia = serializer.Deserialize(dto, dto.EditData, dto.EditDataRaw, true); + + var publishedContentData = new ContentData( + dto.EditName, + null, + dto.VersionId, + dto.EditVersionDate, + dto.CreatorId, + dto.EditTemplateId == 0 ? null : dto.EditTemplateId, + true, + deserializedMedia?.PropertyData, + deserializedMedia?.CultureData); + + return new ContentCacheNode + { + Id = dto.Id, + Key = dto.Key, + SortOrder = dto.SortOrder, + CreateDate = dto.CreateDate, + CreatorId = dto.CreatorId, + ContentTypeId = dto.ContentTypeId, + Data = publishedContentData, + IsDraft = false, + }; + } + + private IEnumerable GetContentNodeDtos(Sql sql) + { + // We need to page here. We don't want to iterate over every single row in one connection cuz this can cause an SQL Timeout. + // We also want to read with a db reader and not load everything into memory, QueryPaged lets us do that. + // QueryPaged is very slow on large sites however, so use fetch if UsePagedSqlQuery is disabled. + IEnumerable dtos; + if (_nucacheSettings.Value.UsePagedSqlQuery) + { + // Use a more efficient COUNT query + Sql? sqlCountQuery = SqlContentSourcesCount() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)); + + Sql? sqlCount = + SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl"); + + dtos = Database.QueryPaged(_nucacheSettings.Value.SqlPageSize, sql, sqlCount); + } + else + { + dtos = Database.Fetch(sql); + } + + return dtos; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs new file mode 100644 index 000000000000..47c18c07e13e --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs @@ -0,0 +1,57 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence; + +internal interface IDatabaseCacheRepository +{ + Task DeleteContentItemAsync(int id); + + Task GetContentSourceAsync(int id, bool preview = false); + + Task GetMediaSourceAsync(int id); + + IEnumerable GetContentByContentTypeKey(IEnumerable keys); + + /// + /// Refreshes the nucache database row for the given cache node /> + /// + /// A representing the asynchronous operation. + Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState); + + /// + /// Refreshes the nucache database row for the given cache node /> + /// + /// A representing the asynchronous operation. + Task RefreshMediaAsync(ContentCacheNode contentCacheNode); + + /// + /// Rebuilds the caches for content, media and/or members based on the content type ids specified + /// + /// + /// If not null will process content for the matching content types, if empty will process all + /// content + /// + /// + /// If not null will process content for the matching media types, if empty will process all + /// media + /// + /// + /// If not null will process content for the matching members types, if empty will process all + /// members + /// + void Rebuild( + IReadOnlyCollection? contentTypeIds = null, + IReadOnlyCollection? mediaTypeIds = null, + IReadOnlyCollection? memberTypeIds = null); + + /// + /// Verifies the content cache by asserting that every document should have a corresponding row for edited properties and if published, + /// may have a corresponding row for published properties + /// + bool VerifyContentDbCache(); + + /// + /// Rebuilds the caches for content, media and/or members based on the content type ids specified + /// + bool VerifyMediaDbCache(); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs b/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs new file mode 100644 index 000000000000..80897e47ac15 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects +[ImmutableObject(true)] +[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys +public sealed class PropertyData +{ + private string? _culture; + private string? _segment; + + [DataMember(Order = 0)] + [JsonConverter(typeof(JsonStringInternConverter))] + [DefaultValue("")] + [JsonPropertyName("c")] + public string? Culture + { + get => _culture; + set => _culture = + value ?? throw new ArgumentNullException( + nameof(value)); // TODO: or fallback to string.Empty? CANNOT be null + } + + [DataMember(Order = 1)] + [JsonConverter(typeof(JsonStringInternConverter))] + [DefaultValue("")] + [JsonPropertyName("s")] + public string? Segment + { + get => _segment; + set => _segment = + value ?? throw new ArgumentNullException( + nameof(value)); // TODO: or fallback to string.Empty? CANNOT be null + } + + [DataMember(Order = 2)] + [JsonPropertyName("v")] + public object? Value { get; set; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs new file mode 100644 index 000000000000..21bb651d59df --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs @@ -0,0 +1,195 @@ +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +internal class PublishedContent : PublishedContentBase +{ + private IPublishedProperty[] _properties; + private readonly ContentNode _contentNode; + private IReadOnlyDictionary? _cultures; + private readonly string? _urlSegment; + private readonly IReadOnlyDictionary? _cultureInfos; + private readonly string _contentName; + private readonly bool _published; + + public PublishedContent( + ContentNode contentNode, + bool preview, + IElementsCache elementsCache, + IVariationContextAccessor variationContextAccessor) + : base(variationContextAccessor) + { + VariationContextAccessor = variationContextAccessor; + _contentNode = contentNode; + ContentData? contentData = preview ? _contentNode.DraftModel : _contentNode.PublishedModel; + if (contentData is null) + { + throw new ArgumentNullException(nameof(contentData)); + } + + _cultureInfos = contentData.CultureInfos; + _contentName = contentData.Name; + _urlSegment = contentData.UrlSegment; + _published = contentData.Published; + + var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()]; + var i = 0; + foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes) + { + // add one property per property type - this is required, for the indexing to work + // if contentData supplies pdatas, use them, else use null + contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null + properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel); + } + + _properties = properties; + + Id = contentNode.Id; + Key = contentNode.Key; + CreatorId = contentNode.CreatorId; + CreateDate = contentNode.CreateDate; + SortOrder = contentNode.SortOrder; + WriterId = contentData.WriterId; + TemplateId = contentData.TemplateId; + UpdateDate = contentData.VersionDate; + } + + public override IPublishedContentType ContentType => _contentNode.ContentType; + + public override Guid Key { get; } + + public override IEnumerable Properties => _properties; + + public override int Id { get; } + + public override int SortOrder { get; } + + // TODO: Remove path. + public override string Path => string.Empty; + + public override int? TemplateId { get; } + + public override int CreatorId { get; } + + public override DateTime CreateDate { get; } + + public override int WriterId { get; } + + public override DateTime UpdateDate { get; } + + public bool IsPreviewing { get; } = false; + + // Needed for publishedProperty + internal IVariationContextAccessor VariationContextAccessor { get; } + + public override int Level { get; } = 0; + + public override IEnumerable ChildrenForAllCultures { get; } = Enumerable.Empty(); + + public override IPublishedContent? Parent { get; } = null!; + + + /// + public override IReadOnlyDictionary Cultures + { + get + { + if (_cultures != null) + { + return _cultures; + } + + if (!ContentType.VariesByCulture()) + { + return _cultures = new Dictionary + { + { string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) }, + }; + } + + if (_cultureInfos == null) + { + throw new PanicException("_contentDate.CultureInfos is null."); + } + + + return _cultures = _cultureInfos + .ToDictionary( + x => x.Key, + x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), + StringComparer.OrdinalIgnoreCase); + } + } + + /// + public override PublishedItemType ItemType => _contentNode.ContentType.ItemType; + + public override IPublishedProperty? GetProperty(string alias) + { + var index = _contentNode.ContentType.GetPropertyIndex(alias); + if (index < 0) + { + return null; // happens when 'alias' does not match a content type property alias + } + + // should never happen - properties array must be in sync with property type + if (index >= _properties.Length) + { + throw new IndexOutOfRangeException( + "Index points outside the properties array, which means the properties array is corrupt."); + } + + IPublishedProperty property = _properties[index]; + return property; + } + + public override bool IsDraft(string? culture = null) + { + // if this is the 'published' published content, nothing can be draft + if (_published) + { + return false; + } + + // not the 'published' published content, and does not vary = must be draft + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty; + + // not the 'published' published content, and varies + // = depends on the culture + return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft; + } + + public override bool IsPublished(string? culture = null) + { + // whether we are the 'draft' or 'published' content, need to determine whether + // there is a 'published' version for the specified culture (or at all, for + // invariant content items) + + // if there is no 'published' published content, no culture can be published + if (!_contentNode.HasPublished) + { + return false; + } + + // if there is a 'published' published content, and does not vary = published + if (!ContentType.VariesByCulture()) + { + return true; + } + + // handle context culture + culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty; + + // there is a 'published' published content, and varies + // = depends on the culture + return _contentNode.HasPublishedCulture(culture); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs new file mode 100644 index 000000000000..4253b1a4c3b7 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +// note +// the whole PublishedMember thing should be refactored because as soon as a member +// is wrapped on in a model, the inner IMember and all associated properties are lost +internal class PublishedMember : PublishedContent, IPublishedMember +{ + private readonly IMember _member; + + public PublishedMember( + IMember member, + ContentNode contentNode, + IElementsCache elementsCache, + IVariationContextAccessor variationContextAccessor) + : base(contentNode, false, elementsCache, variationContextAccessor) => + _member = member; + + public string Email => _member.Email; + + public string UserName => _member.Username; + + public string? Comments => _member.Comments; + + public bool IsApproved => _member.IsApproved; + + public bool IsLockedOut => _member.IsLockedOut; + + public DateTime? LastLockoutDate => _member.LastLockoutDate; + + public DateTime CreationDate => _member.CreateDate; + + public DateTime? LastLoginDate => _member.LastLoginDate; + + public DateTime? LastPasswordChangedDate => _member.LastPasswordChangeDate; +} diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs new file mode 100644 index 000000000000..91e69d9ed7ae --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -0,0 +1,330 @@ +using System.Collections.Concurrent; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Collections; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.HybridCache; + +internal class PublishedProperty : PublishedPropertyBase +{ + private readonly PublishedContent _content; + private readonly bool _isPreviewing; + private readonly IElementsCache _elementsCache; + private readonly bool _isMember; + private string? _valuesCacheKey; + + // the invariant-neutral source and inter values + private readonly object? _sourceValue; + private readonly ContentVariation _variations; + private readonly ContentVariation _sourceVariations; + + // the variant and non-variant object values + private bool _interInitialized; + private object? _interValue; + private CacheValues? _cacheValues; + + // the variant source and inter values + private readonly object _locko = new(); + private ConcurrentDictionary? _sourceValues; + + // initializes a published content property with a value + public PublishedProperty( + IPublishedPropertyType propertyType, + PublishedContent content, + PropertyData[]? sourceValues, + IElementsCache elementsElementsCache, + PropertyCacheLevel referenceCacheLevel = PropertyCacheLevel.Element) + : base(propertyType, referenceCacheLevel) + { + if (sourceValues != null) + { + foreach (PropertyData sourceValue in sourceValues) + { + if (sourceValue.Culture == string.Empty && sourceValue.Segment == string.Empty) + { + _sourceValue = sourceValue.Value; + } + else + { + EnsureSourceValuesInitialized(); + + _sourceValues![new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)] + = new SourceInterValue + { + Culture = sourceValue.Culture, + Segment = sourceValue.Segment, + SourceValue = sourceValue.Value, + }; + } + } + } + + _content = content; + _isPreviewing = content.IsPreviewing; + _isMember = content.ContentType.ItemType == PublishedItemType.Member; + _elementsCache = elementsElementsCache; + + // this variable is used for contextualizing the variation level when calculating property values. + // it must be set to the union of variance (the combination of content type and property type variance). + _variations = propertyType.Variations | content.ContentType.Variations; + _sourceVariations = propertyType.Variations; + } + + // used to cache the CacheValues of this property + internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_content.Key, Alias, _isPreviewing); + + private string PropertyCacheValues(Guid contentUid, string typeAlias, bool previewing) + { + if (previewing) + { + return "Cache.Property.CacheValues[D:" + contentUid + ":" + typeAlias + "]"; + } + + return "Cache.Property.CacheValues[P:" + contentUid + ":" + typeAlias + "]"; + } + + // determines whether a property has value + public override bool HasValue(string? culture = null, string? segment = null) + { + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + + var value = GetSourceValue(culture, segment); + var hasValue = PropertyType.IsValue(value, PropertyValueLevel.Source); + if (hasValue.HasValue) + { + return hasValue.Value; + } + + return PropertyType.IsValue(GetInterValue(culture, segment), PropertyValueLevel.Object) ?? false; + } + + public override object? GetSourceValue(string? culture = null, string? segment = null) + { + _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment); + + // source values are tightly bound to the property/schema culture and segment configurations, so we need to + // sanitize the contextualized culture/segment states before using them to access the source values. + culture = _sourceVariations.VariesByCulture() ? culture : string.Empty; + segment = _sourceVariations.VariesBySegment() ? segment : string.Empty; + + if (culture == string.Empty && segment == string.Empty) + { + return _sourceValue; + } + + if (_sourceValues == null) + { + return null; + } + + return _sourceValues.TryGetValue( + new CompositeStringStringKey(culture, segment), + out SourceInterValue? sourceValue) + ? sourceValue.SourceValue + : null; + } + + private object? GetInterValue(string? culture, string? segment) + { + if (culture is "" && segment is "") + { + if (_interInitialized) + { + return _interValue; + } + + _interValue = PropertyType.ConvertSourceToInter(_content, _sourceValue, _isPreviewing); + _interInitialized = true; + return _interValue; + } + + return PropertyType.ConvertSourceToInter(_content, GetSourceValue(culture, segment), _isPreviewing); + } + + public override object? GetValue(string? culture = null, string? segment = null) + { + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + + object? value; + CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); + + // initial reference cache level always is .Content + const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; + + if (cacheValues.ObjectInitialized) + { + return cacheValues.ObjectValue; + } + + cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing); + cacheValues.ObjectInitialized = true; + value = cacheValues.ObjectValue; + + return value; + } + + private CacheValues GetCacheValues(PropertyCacheLevel cacheLevel) + { + CacheValues cacheValues; + IAppCache? cache; + switch (cacheLevel) + { + case PropertyCacheLevel.None: + // never cache anything + cacheValues = new CacheValues(); + break; + case PropertyCacheLevel.Snapshot: // Snapshot is obsolete, so for now treat as element + case PropertyCacheLevel.Element: + // cache within the property object itself, ie within the content object + cacheValues = _cacheValues ??= new CacheValues(); + break; + case PropertyCacheLevel.Elements: + // cache within the elements cache, unless previewing, then use the snapshot or + // elements cache (if we don't want to pollute the elements cache with short-lived + // data) depending on settings + // for members, always cache in the snapshot cache - never pollute elements cache + cache = _isMember == false ? _elementsCache : null; + cacheValues = GetCacheValues(cache); + break; + default: + throw new InvalidOperationException("Invalid cache level."); + } + + return cacheValues; + } + + private CacheValues GetCacheValues(IAppCache? cache) + { + // no cache, don't cache + if (cache == null) + { + return new CacheValues(); + } + + return (CacheValues)cache.Get(ValuesCacheKey, () => new CacheValues())!; + } + + public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) + { + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + + object? value; + CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); + + + // initial reference cache level always is .Content + const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; + + object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding); + value = expanding + ? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject) + : GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject); + + return value; + } + + private object? GetDeliveryApiDefaultObject(CacheValue cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiDefaultObjectInitialized == false) + { + cacheValues.DeliveryApiDefaultObjectValue = getValue(); + cacheValues.DeliveryApiDefaultObjectInitialized = true; + } + + return cacheValues.DeliveryApiDefaultObjectValue; + } + + private object? GetDeliveryApiExpandedObject(CacheValue cacheValues, Func getValue) + { + if (cacheValues.DeliveryApiExpandedObjectInitialized == false) + { + cacheValues.DeliveryApiExpandedObjectValue = getValue(); + cacheValues.DeliveryApiExpandedObjectInitialized = true; + } + + return cacheValues.DeliveryApiExpandedObjectValue; + } + + private class SourceInterValue + { + private string? _culture; + private string? _segment; + + public string? Culture + { + get => _culture; + internal set => _culture = value?.ToLowerInvariant(); + } + + public string? Segment + { + get => _segment; + internal set => _segment = value?.ToLowerInvariant(); + } + + public object? SourceValue { get; set; } + } + + private class CacheValues : CacheValue + { + private readonly object _locko = new(); + private ConcurrentDictionary? _values; + + public CacheValue For(string? culture, string? segment) + { + if (culture == string.Empty && segment == string.Empty) + { + return this; + } + + if (_values == null) + { + lock (_locko) + { + _values ??= InitializeConcurrentDictionary(); + } + } + + var k = new CompositeStringStringKey(culture, segment); + + CacheValue value = _values.GetOrAdd(k, _ => new CacheValue()); + + return value; + } + } + + private class CacheValue + { + public bool ObjectInitialized { get; set; } + + public object? ObjectValue { get; set; } + + public bool DeliveryApiDefaultObjectInitialized { get; set; } + + public object? DeliveryApiDefaultObjectValue { get; set; } + + public bool DeliveryApiExpandedObjectInitialized { get; set; } + + public object? DeliveryApiExpandedObjectValue { get; set; } + } + + private static ConcurrentDictionary InitializeConcurrentDictionary() + where TKey : notnull + => new(-1, 5); + + private void EnsureSourceValuesInitialized() + { + if (_sourceValues is not null) + { + return; + } + + lock (_locko) + { + _sourceValues ??= InitializeConcurrentDictionary(); + } + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataModel.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataModel.cs new file mode 100644 index 000000000000..bb84c0998e9e --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataModel.cs @@ -0,0 +1,30 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using MessagePack; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// The content model stored in the content cache database table serialized as JSON +/// +[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys +public sealed class ContentCacheDataModel +{ + [DataMember(Order = 0)] + [JsonPropertyName("pd")] + [JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter))] + [MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter))] + public Dictionary? PropertyData { get; set; } + + [DataMember(Order = 1)] + [JsonPropertyName("cd")] + [JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter))] + [MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter))] + public Dictionary? CultureData { get; set; } + + // TODO: Remove this when routing cache is in place + [DataMember(Order = 2)] + [JsonPropertyName("us")] + public string? UrlSegment { get; set; } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializationResult.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializationResult.cs new file mode 100644 index 000000000000..68b80d984785 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializationResult.cs @@ -0,0 +1,47 @@ +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// The serialization result from for which the serialized value +/// will be either a string or a byte[] +/// +public struct ContentCacheDataSerializationResult : IEquatable +{ + public ContentCacheDataSerializationResult(string? stringData, byte[]? byteData) + { + StringData = stringData; + ByteData = byteData; + } + + public string? StringData { get; } + + public byte[]? ByteData { get; } + + public static bool operator ==(ContentCacheDataSerializationResult left, ContentCacheDataSerializationResult right) + => left.Equals(right); + + public static bool operator !=(ContentCacheDataSerializationResult left, ContentCacheDataSerializationResult right) + => !(left == right); + + public override bool Equals(object? obj) + => obj is ContentCacheDataSerializationResult result && Equals(result); + + public bool Equals(ContentCacheDataSerializationResult other) + => StringData == other.StringData && + EqualityComparer.Default.Equals(ByteData, other.ByteData); + + public override int GetHashCode() + { + var hashCode = 1910544615; + if (StringData is not null) + { + hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(StringData); + } + + if (ByteData is not null) + { + hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(ByteData); + } + + return hashCode; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializerEntityType.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializerEntityType.cs new file mode 100644 index 000000000000..7cf61a058308 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/ContentCacheDataSerializerEntityType.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +[Flags] +public enum ContentCacheDataSerializerEntityType +{ + Document = 1, + Media = 2, + Member = 4, +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializer.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializer.cs new file mode 100644 index 000000000000..a46c667a4dd7 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializer.cs @@ -0,0 +1,22 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// Serializes/Deserializes document to the SQL Database as a string +/// +/// +/// Resolved from the . This cannot be resolved from DI. +/// +internal interface IContentCacheDataSerializer +{ + /// + /// Deserialize the data into a + /// + ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published); + + /// + /// Serializes the + /// + ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializerFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializerFactory.cs new file mode 100644 index 000000000000..32d6c71795fc --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/IContentCacheDataSerializerFactory.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +internal interface IContentCacheDataSerializerFactory +{ + /// + /// Gets or creates a new instance of + /// + /// + /// + /// This method may return the same instance, however this depends on the state of the application and if any + /// underlying data has changed. + /// This method may also be used to initialize anything before a serialization/deserialization session occurs. + /// + IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializer.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializer.cs new file mode 100644 index 000000000000..a580b07b373a --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializer.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + public ContentCacheDataModel? Deserialize( + IReadOnlyContentBase content, + string? stringData, + byte[]? byteData, + bool published) + { + if (stringData == null && byteData != null) + { + throw new NotSupportedException( + $"{typeof(JsonContentNestedDataSerializer)} does not support byte[] serialization"); + } + + return JsonSerializer.Deserialize(stringData!, _jsonSerializerOptions); + } + + /// + public ContentCacheDataSerializationResult Serialize( + IReadOnlyContentBase content, + ContentCacheDataModel model, + bool published) + { + var json = JsonSerializer.Serialize(model, _jsonSerializerOptions); + return new ContentCacheDataSerializationResult(json, null); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializerFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializerFactory.cs new file mode 100644 index 000000000000..7353953f4a57 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/JsonContentNestedDataSerializerFactory.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +internal class JsonContentNestedDataSerializerFactory : IContentCacheDataSerializerFactory +{ + private readonly Lazy _serializer = new(); + + public IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types) => _serializer.Value; +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/LazyCompressedString.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/LazyCompressedString.cs new file mode 100644 index 000000000000..84c74ab5cfce --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/LazyCompressedString.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Text; +using K4os.Compression.LZ4; +using Umbraco.Cms.Core.Exceptions; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// Lazily decompresses a LZ4 Pickler compressed UTF8 string +/// +[DebuggerDisplay("{Display}")] +internal struct LazyCompressedString +{ + private readonly object _locker; + private byte[]? _bytes; + private string? _str; + + /// + /// Constructor + /// + /// LZ4 Pickle compressed UTF8 String + public LazyCompressedString(byte[] bytes) + { + _locker = new object(); + _bytes = bytes; + _str = null; + } + + /// + /// Used to display debugging output since ToString() can only be called once + /// + private string Display + { + get + { + if (_str != null) + { + return $"Decompressed: {_str}"; + } + + lock (_locker) + { + if (_str != null) + { + // double check + return $"Decompressed: {_str}"; + } + + if (_bytes == null) + { + // This shouldn't happen + throw new PanicException("Bytes have already been cleared"); + } + + return $"Compressed Bytes: {_bytes.Length}"; + } + } + } + + public static implicit operator string(LazyCompressedString l) => l.ToString(); + + public byte[] GetBytes() + { + if (_bytes == null) + { + throw new InvalidOperationException("The bytes have already been expanded"); + } + + return _bytes; + } + + /// + /// Returns the decompressed string from the bytes. This methods can only be called once. + /// + /// + /// Throws if this is called more than once + public string DecompressString() + { + if (_str != null) + { + return _str; + } + + lock (_locker) + { + if (_str != null) + { + // double check + return _str; + } + + if (_bytes == null) + { + throw new InvalidOperationException("Bytes have already been cleared"); + } + + _str = Encoding.UTF8.GetString(LZ4Pickler.Unpickle(_bytes)); + _bytes = null; + } + + return _str; + } + + public override string ToString() => DecompressString(); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs new file mode 100644 index 000000000000..7527b21e4e07 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs @@ -0,0 +1,27 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// A MessagePack formatter (deserializer) for a string key dictionary that uses for the key string comparison and interns the string. +/// +/// The type of the value. +public sealed class MessagePackDictionaryStringInternIgnoreCaseFormatter : DictionaryFormatterBase, Dictionary.Enumerator, Dictionary> +{ + /// + protected override void Add(Dictionary collection, int index, string key, TValue value, MessagePackSerializerOptions options) + => collection.Add(string.Intern(key), value); + + /// + protected override Dictionary Complete(Dictionary intermediateCollection) + => intermediateCollection; + + /// + protected override Dictionary.Enumerator GetSourceEnumerator(Dictionary source) + => source.GetEnumerator(); + + /// + protected override Dictionary Create(int count, MessagePackSerializerOptions options) + => new(count, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializer.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializer.cs new file mode 100644 index 000000000000..ec4e047d29d4 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializer.cs @@ -0,0 +1,147 @@ +using System.Text; +using K4os.Compression.LZ4; +using MessagePack; +using MessagePack.Resolvers; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +/// +/// Serializes/Deserializes document to the SQL Database as bytes using +/// MessagePack +/// +internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSerializer +{ + private readonly MessagePackSerializerOptions _options; + private readonly IPropertyCacheCompression _propertyOptions; + + public MsgPackContentNestedDataSerializer(IPropertyCacheCompression propertyOptions) + { + _propertyOptions = propertyOptions ?? throw new ArgumentNullException(nameof(propertyOptions)); + + MessagePackSerializerOptions? defaultOptions = ContractlessStandardResolver.Options; + IFormatterResolver? resolver = CompositeResolver.Create( + + // TODO: We want to be able to intern the strings for aliases when deserializing like we do for Newtonsoft but I'm unsure exactly how + // to do that but it would seem to be with a custom message pack resolver but I haven't quite figured out based on the docs how + // to do that since that is part of the int key -> string mapping operation, might have to see the source code to figure that one out. + // There are docs here on how to build one of these: https://github.com/neuecc/MessagePack-CSharp/blob/master/README.md#low-level-api-imessagepackformattert + // and there are a couple examples if you search on google for them but this will need to be a separate project. + // NOTE: resolver custom types first + // new ContentNestedDataResolver(), + + // finally use standard resolver + defaultOptions.Resolver); + + _options = defaultOptions + .WithResolver(resolver) + .WithCompression(MessagePackCompression.Lz4BlockArray) + .WithSecurity(MessagePackSecurity.UntrustedData); + } + + public ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published) + { + if (byteData != null) + { + ContentCacheDataModel? cacheModel = + MessagePackSerializer.Deserialize(byteData, _options); + Expand(content, cacheModel, published); + return cacheModel; + } + + if (stringData != null) + { + // NOTE: We don't really support strings but it's possible if manually used (i.e. tests) + var bin = Convert.FromBase64String(stringData); + ContentCacheDataModel? cacheModel = MessagePackSerializer.Deserialize(bin, _options); + Expand(content, cacheModel, published); + return cacheModel; + } + + return null; + } + + public ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published) + { + Compress(content, model, published); + var bytes = MessagePackSerializer.Serialize(model, _options); + return new ContentCacheDataSerializationResult(null, bytes); + } + + public string ToJson(byte[] bin) + { + var json = MessagePackSerializer.ConvertToJson(bin, _options); + return json; + } + + /// + /// Used during serialization to compress properties + /// + /// + /// + /// + /// + /// This will essentially 'double compress' property data. The MsgPack data as a whole will already be compressed + /// but this will go a step further and double compress property data so that it is stored in the nucache file + /// as compressed bytes and therefore will exist in memory as compressed bytes. That is, until the bytes are + /// read/decompressed as a string to be displayed on the front-end. This allows for potentially a significant + /// memory savings but could also affect performance of first rendering pages while decompression occurs. + /// + private void Compress(IReadOnlyContentBase content, ContentCacheDataModel model, bool published) + { + if (model.PropertyData is null) + { + return; + } + + foreach (KeyValuePair propertyAliasToData in model.PropertyData) + { + if (_propertyOptions.IsCompressed(content, propertyAliasToData.Key, published)) + { + foreach (PropertyData property in propertyAliasToData.Value.Where(x => + x.Value != null && x.Value is string)) + { + if (property.Value is string propertyValue) + { + property.Value = LZ4Pickler.Pickle(Encoding.UTF8.GetBytes(propertyValue)); + } + } + + foreach (PropertyData property in propertyAliasToData.Value.Where(x => + x.Value != null && x.Value is int intVal)) + { + property.Value = Convert.ToBoolean((int?)property.Value); + } + } + } + } + + /// + /// Used during deserialization to map the property data as lazy or expand the value + /// + /// + /// + /// + private void Expand(IReadOnlyContentBase content, ContentCacheDataModel nestedData, bool published) + { + if (nestedData.PropertyData is null) + { + return; + } + + foreach (KeyValuePair propertyAliasToData in nestedData.PropertyData) + { + if (_propertyOptions.IsCompressed(content, propertyAliasToData.Key, published)) + { + foreach (PropertyData property in propertyAliasToData.Value.Where(x => x.Value != null)) + { + if (property.Value is byte[] byteArrayValue) + { + property.Value = new LazyCompressedString(byteArrayValue); + } + } + } + } + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializerFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializerFactory.cs new file mode 100644 index 000000000000..f75f83ab73a5 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Serialization/MsgPackContentNestedDataSerializerFactory.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization; + +internal class MsgPackContentNestedDataSerializerFactory : IContentCacheDataSerializerFactory +{ + private readonly IPropertyCacheCompressionOptions _compressionOptions; + private readonly IContentTypeService _contentTypeService; + private readonly ConcurrentDictionary<(int, string, bool), bool> _isCompressedCache = new(); + private readonly IMediaTypeService _mediaTypeService; + private readonly IMemberTypeService _memberTypeService; + private readonly PropertyEditorCollection _propertyEditors; + + public MsgPackContentNestedDataSerializerFactory( + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + PropertyEditorCollection propertyEditors, + IPropertyCacheCompressionOptions compressionOptions) + { + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _memberTypeService = memberTypeService; + _propertyEditors = propertyEditors; + _compressionOptions = compressionOptions; + } + + public IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types) + { + // Depending on which entity types are being requested, we need to look up those content types + // to initialize the compression options. + // We need to initialize these options now so that any data lookups required are completed and are not done while the content cache + // is performing DB queries which will result in errors since we'll be trying to query with open readers. + // NOTE: The calls to GetAll() below should be cached if the data has not been changed. + var contentTypes = new Dictionary(); + if ((types & ContentCacheDataSerializerEntityType.Document) == ContentCacheDataSerializerEntityType.Document) + { + foreach (IContentType ct in _contentTypeService.GetAll()) + { + contentTypes[ct.Id] = ct; + } + } + + if ((types & ContentCacheDataSerializerEntityType.Media) == ContentCacheDataSerializerEntityType.Media) + { + foreach (IMediaType ct in _mediaTypeService.GetAll()) + { + contentTypes[ct.Id] = ct; + } + } + + if ((types & ContentCacheDataSerializerEntityType.Member) == ContentCacheDataSerializerEntityType.Member) + { + foreach (IMemberType ct in _memberTypeService.GetAll()) + { + contentTypes[ct.Id] = ct; + } + } + + var compression = + new PropertyCacheCompression(_compressionOptions, contentTypes, _propertyEditors, _isCompressedCache); + var serializer = new MsgPackContentNestedDataSerializer(compression); + + return serializer; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs new file mode 100644 index 000000000000..b0aa93679392 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -0,0 +1,174 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Cms.Infrastructure.HybridCache.Persistence; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +internal sealed class DocumentCacheService : IDocumentCacheService +{ + private readonly IDatabaseCacheRepository _databaseCacheRepository; + private readonly IIdKeyMap _idKeyMap; + private readonly ICoreScopeProvider _scopeProvider; + private readonly Microsoft.Extensions.Caching.Hybrid.HybridCache _hybridCache; + private readonly IPublishedContentFactory _publishedContentFactory; + private readonly ICacheNodeFactory _cacheNodeFactory; + + + public DocumentCacheService( + IDatabaseCacheRepository databaseCacheRepository, + IIdKeyMap idKeyMap, + ICoreScopeProvider scopeProvider, + Microsoft.Extensions.Caching.Hybrid.HybridCache hybridCache, + IPublishedContentFactory publishedContentFactory, + ICacheNodeFactory cacheNodeFactory) + { + _databaseCacheRepository = databaseCacheRepository; + _idKeyMap = idKeyMap; + _scopeProvider = scopeProvider; + _hybridCache = hybridCache; + _publishedContentFactory = publishedContentFactory; + _cacheNodeFactory = cacheNodeFactory; + } + + // TODO: Stop using IdKeyMap for these, but right now we both need key and id for caching.. + public async Task GetByKeyAsync(Guid key, bool preview = false) + { + Attempt idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document); + if (idAttempt.Success is false) + { + return null; + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( + GetCacheKey(key, preview), // Unique key to the cache entry + async cancel => await _databaseCacheRepository.GetContentSourceAsync(idAttempt.Result, preview)); + + scope.Complete(); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview); + } + + public async Task GetByIdAsync(int id, bool preview = false) + { + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); + if (keyAttempt.Success is false) + { + return null; + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( + GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry + async cancel => await _databaseCacheRepository.GetContentSourceAsync(id, preview)); + scope.Complete(); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview); + } + + public async Task SeedAsync(IReadOnlyCollection contentTypeKeys) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + IEnumerable contentCacheNodes = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeKeys); + foreach (ContentCacheNode contentCacheNode in contentCacheNodes) + { + if (contentCacheNode.IsDraft) + { + continue; + } + + // TODO: Make these expiration dates configurable. + // Never expire seeded values, we cannot do TimeSpan.MaxValue sadly, so best we can do is a year. + var entryOptions = new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromDays(365), + LocalCacheExpiration = TimeSpan.FromDays(365), + }; + + await _hybridCache.SetAsync( + GetCacheKey(contentCacheNode.Key, false), + contentCacheNode, + entryOptions); + } + + scope.Complete(); + } + + public async Task HasContentByIdAsync(int id, bool preview = false) + { + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); + if (keyAttempt.Success is false) + { + return false; + } + + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( + GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry + cancel => ValueTask.FromResult(null)); + + if (contentCacheNode is null) + { + await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, preview)); + } + + return contentCacheNode is not null; + } + + public async Task RefreshContentAsync(IContent content) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + // Always set draft node + // We have nodes seperate in the cache, cause 99% of the time, you are only using one + // and thus we won't get too much data when retrieving from the cache. + var draftCacheNode = _cacheNodeFactory.ToContentCacheNode(content, true); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)); + await _databaseCacheRepository.RefreshContentAsync(draftCacheNode, content.PublishedState); + + if (content.PublishedState == PublishedState.Publishing) + { + var publishedCacheNode = _cacheNodeFactory.ToContentCacheNode(content, false); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)); + await _databaseCacheRepository.RefreshContentAsync(publishedCacheNode, content.PublishedState); + } + + scope.Complete(); + } + + private string GetCacheKey(Guid key, bool preview) => preview ? $"{key}+draft" : $"{key}"; + + public async Task DeleteItemAsync(int id) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + await _databaseCacheRepository.DeleteContentItemAsync(id); + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); + await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, true)); + await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, false)); + _idKeyMap.ClearCache(keyAttempt.Result); + _idKeyMap.ClearCache(id); + scope.Complete(); + } + + public void Rebuild(IReadOnlyCollection contentTypeKeys) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + _databaseCacheRepository.Rebuild(contentTypeKeys.ToList()); + IEnumerable contentByContentTypeKey = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeKeys.Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.DocumentType).Result)); + + foreach (ContentCacheNode content in contentByContentTypeKey) + { + _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult(); + + if (content.IsDraft is false) + { + _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult(); + } + } + + scope.Complete(); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs new file mode 100644 index 000000000000..b4cb3019afed --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DomainCacheService.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Changes; +using Umbraco.Extensions; + + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +public class DomainCacheService : IDomainCacheService +{ + private readonly IDomainService _domainService; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly ConcurrentDictionary _domains; + + public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider) + { + _domainService = domainService; + _coreScopeProvider = coreScopeProvider; + _domains = new ConcurrentDictionary(); + } + + public IEnumerable GetAll(bool includeWildcards) + { + return includeWildcards == false + ? _domains.Select(x => x.Value).Where(x => x.IsWildcard == false).OrderBy(x => x.SortOrder) + : _domains.Select(x => x.Value).OrderBy(x => x.SortOrder); + } + + /// + public IEnumerable GetAssigned(int documentId, bool includeWildcards = false) + { + // probably this could be optimized with an index + // but then we'd need a custom DomainStore of some sort + IEnumerable list = _domains.Select(x => x.Value).Where(x => x.ContentId == documentId); + if (includeWildcards == false) + { + list = list.Where(x => x.IsWildcard == false); + } + + return list.OrderBy(x => x.SortOrder); + } + + /// + public bool HasAssigned(int documentId, bool includeWildcards = false) + => documentId > 0 && GetAssigned(documentId, includeWildcards).Any(); + + public void Refresh(DomainCacheRefresher.JsonPayload[] payloads) + { + foreach (DomainCacheRefresher.JsonPayload payload in payloads) + { + switch (payload.ChangeType) + { + case DomainChangeTypes.RefreshAll: + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope()) + { + scope.ReadLock(Constants.Locks.Domains); + LoadDomains(); + scope.Complete(); + } + + break; + case DomainChangeTypes.Remove: + _domains.Remove(payload.Id, out _); + break; + case DomainChangeTypes.Refresh: + IDomain? domain = _domainService.GetById(payload.Id); + if (domain == null) + { + continue; + } + + if (domain.RootContentId.HasValue == false) + { + continue; // anomaly + } + + var culture = domain.LanguageIsoCode; + if (string.IsNullOrWhiteSpace(culture)) + { + continue; // anomaly + } + + var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder); + + // Feels wierd to use key and oldvalue, but we're using neither when updating. + _domains.AddOrUpdate( + domain.Id, + new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder), + (key, oldValue) => newDomain); + break; + } + } + } + + private void LoadDomains() + { + IEnumerable domains = _domainService.GetAll(true); + foreach (Domain domain in domains + .Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false) + .Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder))) + { + _domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain); + } + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/IDocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/IDocumentCacheService.cs new file mode 100644 index 000000000000..794c22b2611b --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/IDocumentCacheService.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +public interface IDocumentCacheService +{ + Task GetByKeyAsync(Guid key, bool preview = false); + + Task GetByIdAsync(int id, bool preview = false); + + Task SeedAsync(IReadOnlyCollection contentTypeKeys); + + Task HasContentByIdAsync(int id, bool preview = false); + + Task RefreshContentAsync(IContent content); + + Task DeleteItemAsync(int id); + + void Rebuild(IReadOnlyCollection contentTypeKeys); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/IMediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/IMediaCacheService.cs new file mode 100644 index 000000000000..ad5ed2d769d3 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/IMediaCacheService.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +public interface IMediaCacheService +{ + Task GetByKeyAsync(Guid key); + + Task GetByIdAsync(int id); + + Task HasContentByIdAsync(int id); + + Task RefreshMediaAsync(IMedia media); + + Task DeleteItemAsync(int id); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs new file mode 100644 index 000000000000..9f8dd949427b --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +public interface IMemberCacheService +{ + Task Get(IMember member); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs new file mode 100644 index 000000000000..9f62072c0d89 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -0,0 +1,120 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Cms.Infrastructure.HybridCache.Persistence; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +internal class MediaCacheService : IMediaCacheService +{ + private readonly IDatabaseCacheRepository _databaseCacheRepository; + private readonly IIdKeyMap _idKeyMap; + private readonly ICoreScopeProvider _scopeProvider; + private readonly Microsoft.Extensions.Caching.Hybrid.HybridCache _hybridCache; + private readonly IPublishedContentFactory _publishedContentFactory; + private readonly ICacheNodeFactory _cacheNodeFactory; + + public MediaCacheService( + IDatabaseCacheRepository databaseCacheRepository, + IIdKeyMap idKeyMap, + ICoreScopeProvider scopeProvider, + Microsoft.Extensions.Caching.Hybrid.HybridCache hybridCache, + IPublishedContentFactory publishedContentFactory, + ICacheNodeFactory cacheNodeFactory) + { + _databaseCacheRepository = databaseCacheRepository; + _idKeyMap = idKeyMap; + _scopeProvider = scopeProvider; + _hybridCache = hybridCache; + _publishedContentFactory = publishedContentFactory; + _cacheNodeFactory = cacheNodeFactory; + } + + public async Task GetByKeyAsync(Guid key) + { + Attempt idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Media); + if (idAttempt.Success is false) + { + 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)); + + scope.Complete(); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode); + } + + public async Task GetByIdAsync(int id) + { + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media); + if (keyAttempt.Success is false) + { + 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(); + return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode); + } + + public async Task HasContentByIdAsync(int id) + { + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media); + if (keyAttempt.Success is false) + { + return false; + } + + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( + $"{keyAttempt.Result}", // Unique key to the cache entry + cancel => ValueTask.FromResult(null)); + + if (contentCacheNode is null) + { + await _hybridCache.RemoveAsync($"{keyAttempt.Result}"); + } + + return contentCacheNode is not null; + } + + + public async Task RefreshMediaAsync(IMedia media) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + // Always set draft node + // We have nodes seperate in the cache, cause 99% of the time, you are only using one + // and thus we won't get too much data when retrieving from the cache. + var cacheNode = _cacheNodeFactory.ToContentCacheNode(media); + await _hybridCache.SetAsync(GetCacheKey(media.Key, false), cacheNode); + await _databaseCacheRepository.RefreshMediaAsync(cacheNode); + scope.Complete(); + } + + public async Task DeleteItemAsync(int id) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + await _databaseCacheRepository.DeleteContentItemAsync(id); + Attempt keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media); + if (keyAttempt.Success) + { + await _hybridCache.RemoveAsync(keyAttempt.Result.ToString()); + } + + _idKeyMap.ClearCache(keyAttempt.Result); + _idKeyMap.ClearCache(id); + + scope.Complete(); + } + + private string GetCacheKey(Guid key, bool preview) => preview ? $"{key}+draft" : $"{key}"; +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MemberCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MemberCacheService.cs new file mode 100644 index 000000000000..f7bc3896fb03 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MemberCacheService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Services; + +internal class MemberCacheService : IMemberCacheService +{ + private readonly IPublishedContentFactory _publishedContentFactory; + + public MemberCacheService(IPublishedContentFactory publishedContentFactory) => _publishedContentFactory = publishedContentFactory; + + public async Task Get(IMember member) => member is null ? null : _publishedContentFactory.ToPublishedMember(member); +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Umbraco.PublishedCache.HybridCache.csproj b/src/Umbraco.PublishedCache.HybridCache/Umbraco.PublishedCache.HybridCache.csproj new file mode 100644 index 000000000000..41fb4becbca3 --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Umbraco.PublishedCache.HybridCache.csproj @@ -0,0 +1,35 @@ + + + + Umbraco.Cms.PublishedCache.HybridCache + Umbraco CMS - Published cache - HybridCache + Contains the published cache assembly needed to run Umbraco CMS. + Umbraco.Cms.Infrastructure.HybridCache + + false + + + + + + + + + + + <_Parameter1>Umbraco.Tests + + + <_Parameter1>Umbraco.Tests.Integration + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + diff --git a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs index b50a0a71cef5..8c4b369234e3 100644 --- a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs @@ -56,6 +56,12 @@ public ContentCache( public IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null) => GetByRoute(PreviewDefault, route, hideTopLevelNode, culture); + public Task GetByIdAsync(int id, bool preview = false) => throw new NotImplementedException(); + + public Task GetByIdAsync(Guid key, bool preview = false) => throw new NotImplementedException(); + + public Task HasByIdAsync(int id, bool preview = false) => throw new NotImplementedException(); + public IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null) { if (route == null) diff --git a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs index 9b3b9704cf32..4c65255aa726 100644 --- a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs @@ -99,4 +99,10 @@ public override IEnumerable GetAtRoot(bool preview, string? c public override IPublishedContentType? GetContentType(Guid key) => _snapshot.GetContentType(key); #endregion + + public Task GetByIdAsync(int id) => throw new NotImplementedException(); + + public Task GetByKeyAsync(Guid key) => throw new NotImplementedException(); + + public Task HasByIdAsync(int id) => throw new NotImplementedException(); } diff --git a/src/Umbraco.PublishedCache.NuCache/MemberCache.cs b/src/Umbraco.PublishedCache.NuCache/MemberCache.cs index 8a23f7c1c04f..0aa89b2c42e0 100644 --- a/src/Umbraco.PublishedCache.NuCache/MemberCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/MemberCache.cs @@ -32,10 +32,11 @@ public MemberCache( public IPublishedContentType GetContentType(int id) => _contentTypeCache.Get(PublishedItemType.Member, id); public IPublishedContentType GetContentType(string alias) => _contentTypeCache.Get(PublishedItemType.Member, alias); + public Task GetAsync(IMember member) => throw new NotImplementedException(); - public IPublishedContent? Get(IMember member) + public IPublishedMember? Get(IMember member) => - PublishedMember.Create( + (IPublishedMember?)PublishedMember.Create( member, GetContentType(member.ContentTypeId), _previewDefault, @@ -58,7 +59,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _contentTypeCache.Dispose(); + // _contentTypeCache.Dispose(); } _disposedValue = true; diff --git a/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs b/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs index 0d67d2a8e353..a65b66cd30ce 100644 --- a/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs +++ b/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs @@ -337,6 +337,7 @@ public IEnumerable GetBranchMediaSources(int id) Sql? sql = SqlMediaSourcesSelect(SqlContentSourcesSelectUmbracoNodeJoin) .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media)) .Append(SqlWhereNodeIdX(SqlContext, id)) + .Append(SqlWhereNodeIdX(SqlContext, id)) .Append(SqlOrderByLevelIdSortOrder(SqlContext)); IContentCacheDataSerializer serializer = diff --git a/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs b/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs index 31f312f7b8ab..32d0ff703945 100644 --- a/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs +++ b/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs @@ -9,7 +9,7 @@ namespace Umbraco.Cms.Infrastructure.PublishedCache; // note // the whole PublishedMember thing should be refactored because as soon as a member // is wrapped on in a model, the inner IMember and all associated properties are lost -internal class PublishedMember : PublishedContent +internal class PublishedMember : PublishedContent, IPublishedMember { private PublishedMember( IMember member, diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index 6754577968e3..0816e655e050 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -100,6 +100,7 @@ public override async ValueTask TransformAsync( } // Check if there is no existing content and return the no content controller + // FIXME: This should be changed to route cache, so instead, if there are any routes, we know there is content. if (!umbracoContext.Content?.HasContent() ?? false) { return new RouteValueDictionary diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 0ddfbbce204a..c8e77d96037e 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -5,16 +5,16 @@ - - + + - - - - - - + + + + + + diff --git a/tests/Umbraco.Tests.Common/Builders/ContentEditingBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentEditingBuilder.cs new file mode 100644 index 000000000000..92f65bbc39ea --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/ContentEditingBuilder.cs @@ -0,0 +1,172 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Builders.Interfaces; +using Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +namespace Umbraco.Cms.Tests.Common.Builders; + +public class ContentEditingBuilder + : BuilderBase, + IWithInvariantNameBuilder, + IWithInvariantPropertiesBuilder, + IWithVariantsBuilder, + IWithKeyBuilder, + IWithContentTypeKeyBuilder, + IWithParentKeyBuilder, + IWithTemplateKeyBuilder +{ + private IContentType _contentType; + private ContentTypeBuilder _contentTypeBuilder; + private IEnumerable _invariantProperties = []; + private IEnumerable _variants = []; + private Guid _contentTypeKey; + private Guid? _parentKey; + private Guid? _templateKey; + private Guid? _key; + private string _invariantName; + + Guid? IWithKeyBuilder.Key + { + get => _key; + set => _key = value; + } + + string IWithInvariantNameBuilder.InvariantName + { + get => _invariantName; + set => _invariantName = value; + } + + IEnumerable IWithInvariantPropertiesBuilder.InvariantProperties + { + get => _invariantProperties; + set => _invariantProperties = value; + } + + IEnumerable IWithVariantsBuilder.Variants + { + get => _variants; + set => _variants = value; + } + + Guid? IWithParentKeyBuilder.ParentKey + { + get => _parentKey; + set => _parentKey = value; + } + + Guid IWithContentTypeKeyBuilder.ContentTypeKey + { + get => _contentTypeKey; + set => _contentTypeKey = value; + } + + Guid? IWithTemplateKeyBuilder.TemplateKey + { + get => _templateKey; + set => _templateKey = value; + } + + public ContentEditingBuilder WithInvariantName(string invariantName) + { + _invariantName = invariantName; + return this; + } + + public ContentEditingBuilder WithInvariantProperty(string alias, object value) + { + var property = new PropertyValueModel { Alias = alias, Value = value }; + _invariantProperties = _invariantProperties.Concat(new[] { property }); + return this; + } + + public ContentEditingBuilder AddVariant(string culture, string segment, string name, + IEnumerable properties) + { + var variant = new VariantModel { Culture = culture, Segment = segment, Name = name, Properties = properties }; + _variants = _variants.Concat(new[] { variant }); + return this; + } + + public ContentEditingBuilder WithParentKey(Guid parentKey) + { + _parentKey = parentKey; + return this; + } + + public ContentEditingBuilder WithTemplateKey(Guid templateKey) + { + _templateKey = templateKey; + return this; + } + + public ContentEditingBuilder WithContentType(IContentType contentType) + { + _contentTypeBuilder = null; + _contentType = contentType; + return this; + } + + public override ContentCreateModel Build() + { + var key = _key ?? Guid.NewGuid(); + var parentKey = _parentKey; + var templateKey = _templateKey; + var invariantName = _invariantName ?? Guid.NewGuid().ToString(); + var invariantProperties = _invariantProperties; + var variants = _variants; + + if (_contentTypeBuilder is null && _contentType is null) + { + throw new InvalidOperationException( + "A content item cannot be constructed without providing a content type. Use AddContentType() or WithContentType()."); + } + + var contentType = _contentType ?? _contentTypeBuilder.Build(); + var content = new ContentCreateModel(); + + content.InvariantName = invariantName; + if (parentKey is not null) + { + content.ParentKey = parentKey; + } + + if (templateKey is not null) + { + content.TemplateKey = templateKey; + } + + content.ContentTypeKey = contentType.Key; + content.Key = key; + content.InvariantProperties = invariantProperties; + content.Variants = variants; + + return content; + } + + public static ContentCreateModel CreateBasicContent(IContentType contentType, Guid? key) => + new ContentEditingBuilder() + .WithKey(key) + .WithContentType(contentType) + .WithInvariantName("Home") + .Build(); + + public static ContentCreateModel CreateSimpleContent(IContentType contentType) => + new ContentEditingBuilder() + .WithContentType(contentType) + .WithInvariantName("Home") + .WithInvariantProperty("title", "Welcome to our Home page") + .Build(); + + public static ContentCreateModel CreateSimpleContent(IContentType contentType, string name, Guid? parentKey) => + new ContentEditingBuilder() + .WithContentType(contentType) + .WithInvariantName(name) + .WithParentKey(parentKey) + .WithInvariantProperty("title", "Welcome to our Home page") + .Build(); +} diff --git a/tests/Umbraco.Tests.Common/Builders/Extensions/ContentEditingBuilderExtensions.cs b/tests/Umbraco.Tests.Common/Builders/Extensions/ContentEditingBuilderExtensions.cs new file mode 100644 index 000000000000..a02c4e5b126a --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Extensions/ContentEditingBuilderExtensions.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Tests.Common.Builders.Interfaces; +using Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +namespace Umbraco.Cms.Tests.Common.Builders.Extensions; + +public static class ContentEditingBuilderExtensions +{ + public static T WithInvariantName(this T Builder, string invariantName) + where T : IWithInvariantNameBuilder + { + Builder.InvariantName = invariantName; + return Builder; + } + + public static T WithInvariantProperties(this T Builder, IEnumerable invariantProperties) + where T : IWithInvariantPropertiesBuilder + { + Builder.InvariantProperties = invariantProperties; + return Builder; + } + + public static T WithVariants(this T Builder, IEnumerable variants) + where T : IWithVariantsBuilder + { + Builder.Variants = variants; + return Builder; + } + + public static T WithKey(this T Builder, Guid? key) + where T : IWithKeyBuilder + { + Builder.Key = key; + return Builder; + } + + public static T WithContentTypeKey(this T Builder, Guid contentTypeKey) + where T : IWithContentTypeKeyBuilder + { + Builder.ContentTypeKey = contentTypeKey; + return Builder; + } + + public static T WithParentKey(this T Builder, Guid? parentKey) + where T : IWithParentKeyBuilder + { + Builder.ParentKey = parentKey; + return Builder; + } + + + public static T WithTemplateKey(this T Builder, Guid? templateKey) + where T : IWithTemplateKeyBuilder + { + Builder.TemplateKey = templateKey; + return Builder; + } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithContentTypeKeyBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithContentTypeKeyBuilder.cs new file mode 100644 index 000000000000..cdb743ca67ac --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithContentTypeKeyBuilder.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +public interface IWithContentTypeKeyBuilder +{ + public Guid ContentTypeKey { get; set; } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantNameBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantNameBuilder.cs new file mode 100644 index 000000000000..27698b13951a --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantNameBuilder.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +public interface IWithInvariantNameBuilder +{ + public string? InvariantName { get; set; } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantPropertiesBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantPropertiesBuilder.cs new file mode 100644 index 000000000000..7320ac6523e2 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithInvariantPropertiesBuilder.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +public interface IWithInvariantPropertiesBuilder +{ + public IEnumerable InvariantProperties { get; set; } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithParentKeyBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithParentKeyBuilder.cs new file mode 100644 index 000000000000..e4fa282a7ce6 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithParentKeyBuilder.cs @@ -0,0 +1,9 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces; + +public interface IWithParentKeyBuilder +{ + Guid? ParentKey { get; set; } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithTemplateKeyBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithTemplateKeyBuilder.cs new file mode 100644 index 000000000000..0b05a70548dd --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithTemplateKeyBuilder.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +public interface IWithTemplateKeyBuilder +{ + public Guid? TemplateKey { get; set; } +} diff --git a/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithVariantsBuilder.cs b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithVariantsBuilder.cs new file mode 100644 index 000000000000..ca4e54f1c480 --- /dev/null +++ b/tests/Umbraco.Tests.Common/Builders/Interfaces/ContentCreateModel/IWithVariantsBuilder.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel; + +public interface IWithVariantsBuilder +{ + public IEnumerable Variants { get; set; } +} diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index cf321d9498cf..b4437397d3f4 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -257,6 +257,7 @@ protected void ConfigureServices(IServiceCollection services) .AddUmbracoCore() .AddWebComponents() .AddNuCache() + .AddUmbracoHybridCache() .AddBackOfficeCore() .AddBackOfficeAuthentication() .AddBackOfficeIdentity() @@ -298,7 +299,7 @@ protected void ConfigureServices(IServiceCollection services) protected virtual void CustomMvcSetup(IMvcBuilder mvcBuilder) { - + } /// diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs new file mode 100644 index 000000000000..10dd0cb46776 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs @@ -0,0 +1,133 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Testing; + +public abstract class UmbracoIntegrationTestWithContentEditing : UmbracoIntegrationTest +{ + protected IContentTypeService ContentTypeService => GetRequiredService(); + + protected ITemplateService TemplateService => GetRequiredService(); + + private ContentEditingService ContentEditingService => + (ContentEditingService)GetRequiredService(); + + private ContentPublishingService ContentPublishingService => + (ContentPublishingService)GetRequiredService(); + + + protected ContentCreateModel Subpage2 { get; private set; } + protected ContentCreateModel Subpage3 { get; private set; } + + protected ContentCreateModel Subpage { get; private set; } + + protected ContentCreateModel Textpage { get; private set; } + + protected ContentScheduleCollection ContentSchedule { get; private set; } + + protected CultureAndScheduleModel CultureAndSchedule { get; private set; } + + protected int TextpageId { get; private set; } + + protected int SubpageId { get; private set; } + + protected int Subpage2Id { get; private set; } + + protected int Subpage3Id { get; private set; } + + protected ContentType ContentType { get; private set; } + + [SetUp] + public new void Setup() => CreateTestData(); + + protected async void CreateTestData() + { + // NOTE Maybe not the best way to create/save test data as we are using the services, which are being tested. + var template = TemplateBuilder.CreateTextPageTemplate("defaultTemplate"); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + // Create and Save ContentType "umbTextpage" -> 1051 (template), 1052 (content type) + ContentType = + ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage", defaultTemplateId: template.Id); + ContentType.Key = new Guid("1D3A8E6E-2EA9-4CC1-B229-1AEE19821522"); + ContentType.AllowedAsRoot = true; + ContentType.AllowedContentTypes = new[] { new ContentTypeSort(ContentType.Key, 0, ContentType.Alias) }; + var contentTypeResult = await ContentTypeService.CreateAsync(ContentType, Constants.Security.SuperUserKey); + Assert.IsTrue(contentTypeResult.Success); + + // Create and Save Content "Homepage" based on "umbTextpage" -> 1053 + Textpage = ContentEditingBuilder.CreateSimpleContent(ContentType); + Textpage.Key = new Guid("B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"); + var createContentResultTextPage = await ContentEditingService.CreateAsync(Textpage, Constants.Security.SuperUserKey); + Assert.IsTrue(createContentResultTextPage.Success); + + if (!Textpage.Key.HasValue) + { + throw new InvalidOperationException("The content page key is null."); + } + + if (createContentResultTextPage.Result.Content != null) + { + TextpageId = createContentResultTextPage.Result.Content.Id; + } + + // Sets the culture and schedule for the content, in this case, we are publishing immediately for all cultures + ContentSchedule = new ContentScheduleCollection(); + CultureAndSchedule = new CultureAndScheduleModel + { + CulturesToPublishImmediately = new HashSet { "*" }, Schedules = ContentSchedule, + }; + + // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054 + Subpage = ContentEditingBuilder.CreateSimpleContent(ContentType, "Text Page 1", Textpage.Key); + var createContentResultSubPage = await ContentEditingService.CreateAsync(Subpage, Constants.Security.SuperUserKey); + Assert.IsTrue(createContentResultSubPage.Success); + + if (!Subpage.Key.HasValue) + { + throw new InvalidOperationException("The content page key is null."); + } + + if (createContentResultSubPage.Result.Content != null) + { + SubpageId = createContentResultSubPage.Result.Content.Id; + } + + await ContentPublishingService.PublishAsync(Subpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1055 + Subpage2 = ContentEditingBuilder.CreateSimpleContent(ContentType, "Text Page 2", Textpage.Key); + var createContentResultSubPage2 = await ContentEditingService.CreateAsync(Subpage2, Constants.Security.SuperUserKey); + Assert.IsTrue(createContentResultSubPage2.Success); + if (!Subpage2.Key.HasValue) + { + throw new InvalidOperationException("The content page key is null."); + } + + if (createContentResultSubPage2.Result.Content != null) + { + Subpage2Id = createContentResultSubPage2.Result.Content.Id; + } + + Subpage3 = ContentEditingBuilder.CreateSimpleContent(ContentType, "Text Page 3", Textpage.Key); + var createContentResultSubPage3 = await ContentEditingService.CreateAsync(Subpage3, Constants.Security.SuperUserKey); + Assert.IsTrue(createContentResultSubPage3.Success); + if (!Subpage3.Key.HasValue) + { + throw new InvalidOperationException("The content page key is null."); + } + + if (createContentResultSubPage3.Result.Content != null) + { + Subpage3Id = createContentResultSubPage3.Result.Content.Id; + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheDocumentTypeTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheDocumentTypeTests.cs new file mode 100644 index 000000000000..f0c70c591196 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheDocumentTypeTests.cs @@ -0,0 +1,82 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCacheDocumentTypeTests : UmbracoIntegrationTestWithContentEditing +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); + + private IPublishedContentTypeCache PublishedContentTypeCache => GetRequiredService(); + + [Test] + public async Task Can_Get_Draft_Content_By_Id() + { + //Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + ContentType.RemovePropertyType("title"); + ContentTypeService.Save(ContentType); + + // Assert + var newTextPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + Assert.IsNull(newTextPage.Value("title")); + } + + [Test] + public async Task Can_Get_Draft_Content_By_Key() + { + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + ContentType.RemovePropertyType("title"); + ContentTypeService.Save(ContentType); + //Assert + var newTextPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + Assert.IsNull(newTextPage.Value("title")); + } + + [Test] + public async Task Content_Gets_Removed_When_DocumentType_Is_Deleted() + { + // Load into cache + var textpage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, preview: true); + Assert.IsNotNull(textpage); + + await ContentTypeService.DeleteAsync(textpage.ContentType.Key, Constants.Security.SuperUserKey); + + var textpageAgain = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, preview: true); + Assert.IsNull(textpageAgain); + } + + + // TODO: Copy this into PublishedContentTypeCache + [Test] + public async Task Can_Get_Published_DocumentType_By_Key() + { + var contentType = PublishedContentTypeCache.Get(PublishedItemType.Content, Textpage.ContentTypeKey); + Assert.IsNotNull(contentType); + var contentTypeAgain = PublishedContentTypeCache.Get(PublishedItemType.Content, Textpage.ContentTypeKey); + Assert.IsNotNull(contentType); + } + + [Test] + public async Task Published_DocumentType_Gets_Deleted() + { + var contentType = PublishedContentTypeCache.Get(PublishedItemType.Content, Textpage.ContentTypeKey); + Assert.IsNotNull(contentType); + + await ContentTypeService.DeleteAsync(contentType.Key, Constants.Security.SuperUserKey); + // PublishedContentTypeCache just explodes if it doesn't exist + Assert.Catch(() => PublishedContentTypeCache.Get(PublishedItemType.Content, Textpage.ContentTypeKey)); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs new file mode 100644 index 000000000000..ac7c55604f02 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs @@ -0,0 +1,205 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache; +using Umbraco.Cms.Infrastructure.HybridCache.Factories; +using Umbraco.Cms.Infrastructure.HybridCache.Persistence; +using Umbraco.Cms.Infrastructure.HybridCache.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithContent +{ + private IPublishedContentCache _mockedCache; + private Mock _mockedNucacheRepository; + private IDocumentCacheService _mockDocumentCacheService; + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + [SetUp] + public void SetUp() + { + _mockedNucacheRepository = new Mock(); + + var contentData = new ContentData( + Textpage.Name, + null, + 1, + Textpage.UpdateDate, + Textpage.CreatorId, + -1, + false, + new Dictionary(), + null); + _mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), It.IsAny())).ReturnsAsync( + new ContentCacheNode() + { + ContentTypeId = Textpage.ContentTypeId, + CreatorId = Textpage.CreatorId, + CreateDate = Textpage.CreateDate, + Id = Textpage.Id, + Key = Textpage.Key, + SortOrder = 0, + Data = contentData, + IsDraft = true, + }); + + _mockedNucacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>())).Returns( + new List() + { + new() + { + ContentTypeId = Textpage.ContentTypeId, + CreatorId = Textpage.CreatorId, + CreateDate = Textpage.CreateDate, + Id = Textpage.Id, + Key = Textpage.Key, + SortOrder = 0, + Data = contentData, + IsDraft = false, + }, + }); + + _mockedNucacheRepository.Setup(r => r.DeleteContentItemAsync(It.IsAny())); + + _mockDocumentCacheService = new DocumentCacheService( + _mockedNucacheRepository.Object, + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService()); + + _mockedCache = new DocumentCache(_mockDocumentCacheService, GetRequiredService()); + } + + [Test] + public async Task Content_Is_Cached_By_Key() + { + var hybridCache = GetRequiredService(); + await hybridCache.RemoveAsync($"{Textpage.Key}+draft"); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Key, true); + var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Key, true); + AssertTextPage(textPage); + AssertTextPage(textPage2); + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Test] + public async Task Content_Is_Cached_By_Id() + { + var hybridCache = GetRequiredService(); + await hybridCache.RemoveAsync($"{Textpage.Key}+draft"); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Id, true); + var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Id, true); + AssertTextPage(textPage); + AssertTextPage(textPage2); + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Test] + public async Task Content_Is_Seeded_By_Id() + { + var schedule = new CultureAndScheduleModel + { + CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(), + }; + + var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + Textpage.Published = true; + await _mockDocumentCacheService.DeleteItemAsync(Textpage.Id); + + await _mockDocumentCacheService.SeedAsync(new [] {Textpage.ContentType.Key}); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Id); + AssertTextPage(textPage); + + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(0)); + } + + [Test] + public async Task Content_Is_Seeded_By_Key() + { + var schedule = new CultureAndScheduleModel + { + CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(), + }; + + var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + Textpage.Published = true; + await _mockDocumentCacheService.DeleteItemAsync(Textpage.Id); + + await _mockDocumentCacheService.SeedAsync(new [] {Textpage.ContentType.Key}); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Key); + AssertTextPage(textPage); + + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(0)); + } + + [Test] + public async Task Content_Is_Not_Seeded_If_Unpublished_By_Id() + { + + await _mockDocumentCacheService.DeleteItemAsync(Textpage.Id); + + await _mockDocumentCacheService.SeedAsync(new [] {Textpage.ContentType.Key}); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Id, true); + AssertTextPage(textPage); + + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Test] + public async Task Content_Is_Not_Seeded_If_Unpublished_By_Key() + { + await _mockDocumentCacheService.DeleteItemAsync(Textpage.Id); + + await _mockDocumentCacheService.SeedAsync(new [] {Textpage.ContentType.Key}); + var textPage = await _mockedCache.GetByIdAsync(Textpage.Key, true); + AssertTextPage(textPage); + + _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + private void AssertTextPage(IPublishedContent textPage) + { + Assert.Multiple(() => + { + Assert.IsNotNull(textPage); + Assert.AreEqual(Textpage.Name, textPage.Name); + Assert.AreEqual(Textpage.Published, textPage.IsPublished()); + }); + AssertProperties(Textpage.Properties, textPage.Properties); + } + + private void AssertProperties(IPropertyCollection propertyCollection, IEnumerable publishedProperties) + { + foreach (var prop in propertyCollection) + { + AssertProperty(prop, publishedProperties.First(x => x.Alias == prop.Alias)); + } + } + + private void AssertProperty(IProperty property, IPublishedProperty publishedProperty) + { + Assert.Multiple(() => + { + Assert.AreEqual(property.Alias, publishedProperty.Alias); + Assert.AreEqual(property.PropertyType.Alias, publishedProperty.PropertyType.Alias); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCachePropertyTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCachePropertyTest.cs new file mode 100644 index 000000000000..0cfc342917b1 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCachePropertyTest.cs @@ -0,0 +1,185 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentPublishing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCachePropertyTest : UmbracoIntegrationTest +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + private ICacheManager CacheManager => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + + [Test] + public async Task Can_Get_Value_From_ContentPicker() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + var textPage = await CreateTextPageDocument(template.Id); + var contentPickerDocument = await CreateContentPickerDocument(template.Id, textPage.Key); + + var contentPickerPage = await CacheManager.Content.GetByIdAsync(contentPickerDocument.Id); + + IPublishedContent contentPickerValue = (IPublishedContent)contentPickerPage.Value("contentPicker"); + Assert.AreEqual(textPage.Key, contentPickerValue.Key); + Assert.AreEqual(textPage.Id, contentPickerValue.Id); + Assert.AreEqual(textPage.Name, contentPickerValue.Name); + Assert.AreEqual("The title value", contentPickerValue.Properties.First(x => x.Alias == "title").GetValue()); + } + + [Test] + public async Task Can_Get_Value_From_Updated_ContentPicker() + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + var textPage = await CreateTextPageDocument(template.Id); + var contentPickerDocument = await CreateContentPickerDocument(template.Id, textPage.Key); + + // Get for caching + var notUpdatedContent = await CacheManager.Content.GetByIdAsync(contentPickerDocument.Id); + + IPublishedContent contentPickerValue = (IPublishedContent)notUpdatedContent.Value("contentPicker"); + Assert.AreEqual("The title value", contentPickerValue.Properties.First(x => x.Alias == "title").GetValue()); + + // Update content + var updateModel = new ContentUpdateModel + { + InvariantName = "Root Create", + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "title", Value = "Updated title" }, + new PropertyValueModel { Alias = "bodyText", Value = "The body text" } + }, + }; + + var updateResult = await ContentEditingService.UpdateAsync(textPage.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(updateResult.Success); + + var publishResult = await ContentPublishingService.PublishAsync( + updateResult.Result.Content!.Key, + new CultureAndScheduleModel() + { + CulturesToPublishImmediately = new HashSet {"*"}, + Schedules = new ContentScheduleCollection(), + }, + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishResult); + + var contentPickerPage = await CacheManager.Content.GetByIdAsync(contentPickerDocument.Id); + IPublishedContent updatedPickerValue = (IPublishedContent)contentPickerPage.Value("contentPicker"); + + + Assert.AreEqual(textPage.Key, updatedPickerValue.Key); + Assert.AreEqual(textPage.Id, updatedPickerValue.Id); + Assert.AreEqual(textPage.Name, updatedPickerValue.Name); + Assert.AreEqual("Updated title", updatedPickerValue.Properties.First(x => x.Alias == "title").GetValue()); + } + + private async Task CreateContentPickerDocument(int templateId, Guid textPageKey) + { + var builder = new ContentTypeBuilder(); + var pickerContentType = (ContentType)builder + .WithAlias("test") + .WithName("TestName") + .AddAllowedTemplate() + .WithId(templateId) + .Done() + .AddPropertyGroup() + .WithName("Content") + .WithSupportsPublishing(true) + .AddPropertyType() + .WithAlias("contentPicker") + .WithName("Content Picker") + .WithDataTypeId(1046) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.ContentPicker) + .WithValueStorageType(ValueStorageType.Integer) + .WithSortOrder(16) + .Done() + .Done() + .Build(); + + pickerContentType.AllowedAsRoot = true; + ContentTypeService.Save(pickerContentType); + + + var createOtherModel = new ContentCreateModel + { + ContentTypeKey = pickerContentType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Test Create", + InvariantProperties = new[] { new PropertyValueModel { Alias = "contentPicker", Value = textPageKey }, }, + }; + + var result = await ContentEditingService.CreateAsync(createOtherModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); + + var publishResult = await ContentPublishingService.PublishAsync( + result.Result.Content!.Key, + new CultureAndScheduleModel() + { + CulturesToPublishImmediately = new HashSet {"*"}, + Schedules = new ContentScheduleCollection(), + }, + Constants.Security.SuperUserKey); + + return result.Result.Content; + } + + private async Task CreateTextPageDocument(int templateId) + { + var textContentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: templateId); + textContentType.AllowedAsRoot = true; + ContentTypeService.Save(textContentType); + + var createModel = new ContentCreateModel + { + ContentTypeKey = textContentType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Root Create", + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "bodyText", Value = "The body text" } + }, + }; + + var createResult = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + var publishResult = await ContentPublishingService.PublishAsync( + createResult.Result.Content!.Key, + new CultureAndScheduleModel() + { + CulturesToPublishImmediately = new HashSet {"*"}, + Schedules = new ContentScheduleCollection(), + }, + Constants.Security.SuperUserKey); + + Assert.IsTrue(publishResult.Success); + return createResult.Result.Content; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheScopeTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheScopeTests.cs new file mode 100644 index 000000000000..8f2ad58ad6cf --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheScopeTests.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCacheScopeTests : UmbracoIntegrationTestWithContentEditing +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private ICoreScopeProvider ICoreScopeProvider => GetRequiredService(); + + [Test] + public async Task Can_Get_Correct_Content_After_Rollback_With_Id() + { + using (ICoreScopeProvider.CreateCoreScope()) + { + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + } + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId); + + // Published page should not be in cache, as we rolled scope back. + Assert.IsNull(textPage); + } + + [Test] + public async Task Can_Get_Correct_Content_After_Rollback_With_Key() + { + using (ICoreScopeProvider.CreateCoreScope()) + { + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + } + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value); + + // Published page should not be in cache, as we rolled scope back. + Assert.IsNull(textPage); + } + + [Test] + public async Task Can_Get_Document_After_Scope_Complete_With_Id() + { + using (var scope = ICoreScopeProvider.CreateCoreScope()) + { + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + scope.Complete(); + } + + // Act + var publishedPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId); + + // Published page should not be in cache, as we rolled scope back. + Assert.IsNotNull(publishedPage); + } + + [Test] + public async Task Can_Get_Document_After_Scope_Completes_With_Key() + { + using (var scope = ICoreScopeProvider.CreateCoreScope()) + { + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + scope.Complete(); + } + + // Act + var publishedPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value); + + // Published page should not be in cache, as we rolled scope back. + Assert.IsNotNull(publishedPage); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs new file mode 100644 index 000000000000..7d8d4123e1f8 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -0,0 +1,518 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCacheTests : UmbracoIntegrationTestWithContentEditing +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private const string NewName = "New Name"; + private const string NewTitle = "New Title"; + + + // Create CRUD Tests for Content, Also cultures. + + [Test] + public async Task Can_Get_Draft_Content_By_Id() + { + //Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + //Assert + AssertTextPage(textPage); + } + + [Test] + public async Task Can_Get_Draft_Content_By_Key() + { + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + AssertTextPage(textPage); + } + + [Test] + public async Task Can_Get_Published_Content_By_Id() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId); + + // Assert + AssertTextPage(textPage); + } + + [Test] + public async Task Can_Get_Published_Content_By_Key() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value); + + // Assert + AssertTextPage(textPage); + } + + [Test] + public async Task Can_Get_Draft_Of_Published_Content_By_Id() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + AssertTextPage(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + + [Test] + public async Task Can_Get_Draft_Of_Published_Content_By_Key() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + AssertTextPage(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + + [Test] + public async Task Can_Get_Updated_Draft_Content_By_Id() + { + // Arrange + Textpage.InvariantName = NewName; + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = NewName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var updatedPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + Assert.AreEqual(NewName, updatedPage.Name); + } + + [Test] + public async Task Can_Get_Updated_Draft_Content_By_Key() + { + // Arrange + Textpage.InvariantName = NewName; + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = NewName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var updatedPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(NewName, updatedPage.Name); + } + + [Test] + [TestCase(true, true)] + [TestCase(false, false)] + // BETTER NAMING, CURRENTLY THIS IS TESTING BOTH THE PUBLISHED AND THE DRAFT OF THE PUBLISHED. + public async Task Can_Get_Updated_Draft_Published_Content_By_Id(bool preview, bool result) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + Textpage.InvariantName = NewName; + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = NewName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, preview); + + // Assert + Assert.AreEqual(result, NewName.Equals(textPage.Name)); + } + + [Test] + [TestCase(true, true)] + [TestCase(false, false)] + // BETTER NAMING, CURRENTLY THIS IS TESTING BOTH THE PUBLISHED AND THE DRAFT OF THE PUBLISHED. + public async Task Can_Get_Updated_Draft_Published_Content_By_Key(bool preview, bool result) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + Textpage.InvariantName = NewName; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = NewName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, preview); + + // Assert + Assert.AreEqual(result, NewName.Equals(textPage.Name)); + } + + [Test] + public async Task Can_Get_Draft_Content_Property_By_Id() + { + // Arrange + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Draft_Content_Property_By_Key() + { + // Arrange + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Published_Content_Property_By_Id() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Published_Content_Property_By_Key() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Draft_Of_Published_Content_Property_By_Id() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Draft_Of_Published_Content_Property_By_Key() + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + var titleValue = Textpage.InvariantProperties.First(x => x.Alias == "title").Value; + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(titleValue, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Updated_Draft_Content_Property_By_Id() + { + // Arrange + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = NewTitle; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, true); + + // Assert + Assert.AreEqual(NewTitle, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Updated_Draft_Content_Property_By_Key() + { + // Arrange + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = NewTitle; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(NewTitle, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Updated_Published_Content_Property_By_Id() + { + // Arrange + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = NewTitle; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(NewTitle, textPage.Value("title")); + } + + [Test] + public async Task Can_Get_Updated_Published_Content_Property_By_Key() + { + // Arrange + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = NewTitle; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value); + + // Assert + Assert.AreEqual(NewTitle, textPage.Value("title")); + } + + [Test] + [TestCase(true, "New Title")] + [TestCase(false, "Welcome to our Home page")] + public async Task Can_Get_Updated_Draft_Of_Published_Content_Property_By_Id(bool preview, string titleName) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = NewTitle; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, preview); + + // Assert + Assert.AreEqual(titleName, textPage.Value("title")); + } + + [Test] + [TestCase(true, "New Name")] + [TestCase(false, "Welcome to our Home page")] + public async Task Can_Get_Updated_Draft_Of_Published_Content_Property_By_Key(bool preview, string titleName) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + Textpage.InvariantProperties.First(x => x.Alias == "title").Value = titleName; + + ContentUpdateModel updateModel = new ContentUpdateModel + { + InvariantName = Textpage.InvariantName, + InvariantProperties = Textpage.InvariantProperties, + Variants = Textpage.Variants, + TemplateKey = Textpage.TemplateKey, + }; + + await ContentEditingService.UpdateAsync(Textpage.Key.Value, updateModel, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, true); + + // Assert + Assert.AreEqual(titleName, textPage.Value("title")); + } + + [Test] + public async Task Can_Not_Get_Deleted_Content_By_Id() + { + // Arrange + var content = await PublishedContentHybridCache.GetByIdAsync(Subpage3Id, true); + Assert.IsNotNull(content); + await ContentEditingService.DeleteAsync(Subpage3.Key.Value, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Subpage3Id, true); + + // Assert + Assert.IsNull(textPage); + } + + [Test] + public async Task Can_Not_Get_Deleted_Content_By_Key() + { + // Arrange + await PublishedContentHybridCache.GetByIdAsync(Subpage3.Key.Value, true); + var result = await ContentEditingService.DeleteAsync(Subpage3.Key.Value, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Subpage3.Key.Value, true); + + // Assert + Assert.IsNull(textPage); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task Can_Not_Get_Deleted_Published_Content_By_Id(bool preview) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + await ContentEditingService.DeleteAsync(Textpage.Key.Value, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(TextpageId, preview); + + // Assert + Assert.IsNull(textPage); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task Can_Not_Get_Deleted_Published_Content_By_Key(bool preview) + { + // Arrange + await ContentPublishingService.PublishAsync(Textpage.Key.Value, CultureAndSchedule, Constants.Security.SuperUserKey); + await ContentEditingService.DeleteAsync(Textpage.Key.Value, Constants.Security.SuperUserKey); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(Textpage.Key.Value, preview); + + // Assert + Assert.IsNull(textPage); + } + + private void AssertTextPage(IPublishedContent textPage) + { + Assert.Multiple(() => + { + Assert.IsNotNull(textPage); + Assert.AreEqual(Textpage.Key, textPage.Key); + Assert.AreEqual(Textpage.ContentTypeKey, textPage.ContentType.Key); + Assert.AreEqual(Textpage.InvariantName, textPage.Name); + }); + + AssertProperties(Textpage.InvariantProperties, textPage.Properties); + } + + private void AssertProperties(IEnumerable propertyCollection, IEnumerable publishedProperties) + { + foreach (var prop in propertyCollection) + { + AssertProperty(prop, publishedProperties.First(x => x.Alias == prop.Alias)); + } + } + + private void AssertProperty(PropertyValueModel property, IPublishedProperty publishedProperty) + { + Assert.Multiple(() => + { + Assert.AreEqual(property.Alias, publishedProperty.Alias); + Assert.AreEqual(property.Value, publishedProperty.GetSourceValue()); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheVariantsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheVariantsTests.cs new file mode 100644 index 000000000000..8f06be20c30e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheVariantsTests.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class DocumentHybridCacheVariantsTests : UmbracoIntegrationTest +{ + private string _englishIsoCode = "en-US"; + private string _danishIsoCode = "da-DK"; + private string _variantTitleAlias = "variantTitle"; + private string _variantTitleName = "Variant Title"; + private string _invariantTitleAlias = "invariantTitle"; + private string _invariantTitleName = "Invariant Title"; + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private ILanguageService LanguageService => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService(); + + private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); + + private IContent VariantPage { get; set; } + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + [SetUp] + public async Task Setup() => await CreateTestData(); + + [Test] + public async Task Can_Set_Invariant_Title() + { + // Arrange + await PublishedContentHybridCache.GetByIdAsync(VariantPage.Id, true); + var updatedInvariantTitle = "Updated Invariant Title"; + var updatedVariantTitle = "Updated Variant Title"; + + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = _invariantTitleAlias, Value = updatedInvariantTitle } + }, + Variants = new [] + { + new VariantModel + { + Culture = _englishIsoCode, + Name = "Updated English Name", + Properties = new [] + { + new PropertyValueModel { Alias = _variantTitleAlias, Value = updatedVariantTitle } + } + }, + new VariantModel + { + Culture = _danishIsoCode, + Name = "Updated Danish Name", + Properties = new [] + { + new PropertyValueModel { Alias = _variantTitleAlias, Value = updatedVariantTitle } + }, + }, + }, + }; + + var result = await ContentEditingService.UpdateAsync(VariantPage.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(VariantPage.Id, true); + + // Assert + using var contextReference = UmbracoContextFactory.EnsureUmbracoContext(); + Assert.AreEqual(updatedInvariantTitle, textPage.Value(_invariantTitleAlias, "", "")); + Assert.AreEqual(updatedVariantTitle, textPage.Value(_variantTitleAlias, _englishIsoCode)); + Assert.AreEqual(updatedVariantTitle, textPage.Value(_variantTitleAlias, _danishIsoCode)); + } + + [Test] + public async Task Can_Set_Invariant_Title_On_One_Culture() + { + // Arrange + await PublishedContentHybridCache.GetByIdAsync(VariantPage.Id, true); + var updatedInvariantTitle = "Updated Invariant Title"; + var updatedVariantTitle = "Updated Invariant Title"; + + + var updateModel = new ContentUpdateModel + { + InvariantProperties = new[] + { + new PropertyValueModel { Alias = _invariantTitleAlias, Value = updatedInvariantTitle } + }, + Variants = new [] + { + new VariantModel + { + Culture = _englishIsoCode, + Name = "Updated English Name", + Properties = new [] + { + new PropertyValueModel { Alias = _variantTitleAlias, Value = updatedVariantTitle } + } + }, + }, + }; + + var result = await ContentEditingService.UpdateAsync(VariantPage.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(VariantPage.Id, true); + + // Assert + using var contextReference = UmbracoContextFactory.EnsureUmbracoContext(); + Assert.AreEqual(updatedInvariantTitle, textPage.Value(_invariantTitleAlias, "", "")); + Assert.AreEqual(updatedVariantTitle, textPage.Value(_variantTitleAlias, _englishIsoCode)); + Assert.AreEqual(_variantTitleName, textPage.Value(_variantTitleAlias, _danishIsoCode)); + } + + + private async Task CreateTestData() + { + // NOTE Maybe not the best way to create/save test data as we are using the services, which are being tested. + var language = new LanguageBuilder() + .WithCultureInfo(_danishIsoCode) + .Build(); + + await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey); + + var contentType = new ContentTypeBuilder() + .WithAlias("cultureVariationTest") + .WithName("Culture Variation Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias(_variantTitleAlias) + .WithName(_variantTitleName) + .WithVariations(ContentVariation.Culture) + .Done() + .AddPropertyType() + .WithAlias(_invariantTitleAlias) + .WithName(_invariantTitleName) + .WithVariations(ContentVariation.Nothing) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + ContentTypeService.Save(contentType); + var rootContentCreateModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + Variants = new[] + { + new VariantModel + { + Culture = "en-US", + Name = "English Page", + Properties = new [] + { + new PropertyValueModel { Alias = _variantTitleAlias, Value = _variantTitleName } + }, + }, + new VariantModel + { + Culture = "da-DK", + Name = "Danish Page", + Properties = new [] + { + new PropertyValueModel { Alias = _variantTitleAlias, Value = _variantTitleName } + }, + }, + }, + }; + + var result = await ContentEditingService.CreateAsync(rootContentCreateModel, Constants.Security.SuperUserKey); + VariantPage = result.Result.Content; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaHybridCacheTests.cs new file mode 100644 index 000000000000..63fc6eb841ae --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaHybridCacheTests.cs @@ -0,0 +1,239 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class MediaHybridCacheTests : UmbracoIntegrationTest +{ + private IPublishedMediaCache PublishedMediaHybridCache => GetRequiredService(); + + private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService(); + + private IMediaTypeService MediaTypeService => GetRequiredService(); + + private IMediaEditingService MediaEditingService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + // TODO: make test with MediaWithCrops + + [Test] + public async Task Can_Get_Media_By_Key() + { + // Arrange + var newMediaType = new MediaTypeBuilder() + .WithAlias("album") + .WithName("Album") + .Build(); + + newMediaType.AllowedAsRoot = true; + MediaTypeService.Save(newMediaType); + + var createModel = new MediaCreateModel + { + ContentTypeKey = newMediaType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Image", + }; + + var result = await MediaEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Act + var media = await PublishedMediaHybridCache.GetByKeyAsync(result.Result.Content.Key); + + // Assert + Assert.IsNotNull(media); + Assert.AreEqual("Image", media.Name); + Assert.AreEqual(newMediaType.Key, media.ContentType.Key); + } + + [Test] + public async Task Can_Get_Media_By_Id() + { + // Arrange + var newMediaType = new MediaTypeBuilder() + .WithAlias("album") + .WithName("Album") + .Build(); + + newMediaType.AllowedAsRoot = true; + MediaTypeService.Save(newMediaType); + + var createModel = new MediaCreateModel + { + ContentTypeKey = newMediaType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Image", + }; + + var result = await MediaEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + + // Act + var media = await PublishedMediaHybridCache.GetByIdAsync(result.Result.Content.Id); + + // Assert + Assert.IsNotNull(media); + Assert.AreEqual("Image", media.Name); + Assert.AreEqual(newMediaType.Key, media.ContentType.Key); + } + + [Test] + public async Task Cannot_Get_Non_Existing_Media_By_Key() + { + // Act + var media = await PublishedMediaHybridCache.GetByKeyAsync(Guid.NewGuid()); + + // Assert + Assert.IsNull(media); + } + + [Test] + public async Task Cannot_Get_Non_Existing_Media_By_Id() + { + // Act + var media = await PublishedMediaHybridCache.GetByIdAsync(124214); + + // Assert + Assert.IsNull(media); + } + + [Test] + public async Task Can_Get_Media_Property_By_Key() + { + // Arrange + var media = await CreateMedia(); + + // Act + var publishedMedia = await PublishedMediaHybridCache.GetByKeyAsync(media.Key); + + UmbracoContextFactory.EnsureUmbracoContext(); + + // Assert + Assert.IsNotNull(media); + Assert.AreEqual("Image", media.Name); + Assert.AreEqual("NewTitle", publishedMedia.Value("title")); + } + + [Test] + public async Task Can_Get_Media_Property_By_Id() + { + // Arrange + var media = await CreateMedia(); + + // Act + var publishedMedia = await PublishedMediaHybridCache.GetByKeyAsync(media.Key); + + UmbracoContextFactory.EnsureUmbracoContext(); + + // Assert + Assert.IsNotNull(publishedMedia); + Assert.AreEqual("Image", publishedMedia.Name); + Assert.AreEqual("NewTitle", publishedMedia.Value("title")); + } + + [Test] + public async Task Can_Get_Updated_Media() + { + // Arrange + var media = await CreateMedia(); + await PublishedMediaHybridCache.GetByIdAsync(media.Id); + + // Act + var updateModel = new MediaUpdateModel() + { + InvariantName = "Update name", + InvariantProperties = new List() + { + new() + { + Alias = "title", + Value = "Updated Title" + } + } + }; + + var updateAttempt = await MediaEditingService.UpdateAsync(media.Key, updateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(updateAttempt.Success); + var publishedMedia = await PublishedMediaHybridCache.GetByIdAsync(media.Id); + UmbracoContextFactory.EnsureUmbracoContext(); + + // Assert + Assert.IsNotNull(publishedMedia); + Assert.AreEqual("Update name", publishedMedia.Name); + Assert.AreEqual("Updated Title", publishedMedia.Value("title")); + } + + [Test] + public async Task Cannot_Get_Deleted_Media_By_Id() + { + // Arrange + var media = await CreateMedia(); + var publishedMedia = await PublishedMediaHybridCache.GetByIdAsync(media.Id); + Assert.IsNotNull(publishedMedia); + + await MediaEditingService.DeleteAsync(media.Key, Constants.Security.SuperUserKey); + + // Act + var deletedMedia = await PublishedMediaHybridCache.GetByIdAsync(media.Id); + + // Assert + Assert.IsNull(deletedMedia); + } + + [Test] + public async Task Cannot_Get_Deleted_Media_By_Key() + { + // Arrange + var media = await CreateMedia(); + var publishedMedia = await PublishedMediaHybridCache.GetByKeyAsync(media.Key); + Assert.IsNotNull(publishedMedia); + + await MediaEditingService.DeleteAsync(media.Key, Constants.Security.SuperUserKey); + + // Act + var deletedMedia = await PublishedMediaHybridCache.GetByKeyAsync(media.Key); + + // Assert + Assert.IsNull(deletedMedia); + } + + private async Task CreateMedia() + { + IMediaType mediaType = MediaTypeBuilder.CreateSimpleMediaType("test", "Test"); + mediaType.AllowedAsRoot = true; + MediaTypeService.Save(mediaType); + + var createModel = new MediaCreateModel + { + ContentTypeKey = mediaType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Image", + InvariantProperties = new List() + { + new() + { + Alias = "title", + Value = "NewTitle" + } + } + }; + + var result = await MediaEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + return result.Result.Content; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberHybridCacheTests.cs new file mode 100644 index 000000000000..9f1c201deb25 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberHybridCacheTests.cs @@ -0,0 +1,81 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] +public class MemberHybridCacheTests : UmbracoIntegrationTest +{ + private IPublishedMemberCache PublishedMemberHybridCache => GetRequiredService(); + + private IMemberEditingService MemberEditingService => GetRequiredService(); + + private IMemberService MemberService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + private IMemberGroupService MemberGroupService => GetRequiredService(); + + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); + + [Test] + public async Task Can_Get_Member_By_Key() + { + Guid key = Guid.NewGuid(); + var createdMember = await CreateMemberAsync(key); + + // Act + var member = await PublishedMemberHybridCache.GetAsync(createdMember); + + // Assert + Assert.IsNotNull(member); + Assert.AreEqual("The title value", member.Value("title")); + Assert.AreEqual("test@test.com", member.Email); + Assert.AreEqual("test", member.UserName); + Assert.IsTrue(member.IsApproved); + Assert.AreEqual("T. Est", member.Name); + } + + private async Task CreateMemberAsync(Guid? key = null, bool titleIsSensitive = false) + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + memberType.SetIsSensitiveProperty("title", titleIsSensitive); + MemberTypeService.Save(memberType); + MemberService.AddRole("RoleOne"); + var group = MemberGroupService.GetByName("RoleOne"); + + var createModel = new MemberCreateModel + { + Key = key, + Email = "test@test.com", + Username = "test", + Password = "SuperSecret123", + IsApproved = true, + ContentTypeKey = memberType.Key, + Roles = new [] { group.Key }, + InvariantName = "T. Est", + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "title", Value = "The title value" }, + new PropertyValueModel { Alias = "author", Value = "The author value" } + } + }; + + var result = await MemberEditingService.CreateAsync(createModel, SuperUser()); + Assert.IsTrue(result.Success); + return result.Result.Content; + } + + private IUser SuperUser() => GetRequiredService().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult(); + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index de8274a94f9d..830f3225300f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index 462205b2318f..679daeca75f0 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -17,6 +17,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.UrlAndDomains; [TestFixture] [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Mapper = true, WithApplication = true, Logger = UmbracoTestOptions.Logger.Console)] +[Platform("Linux", Reason = "This uses too much memory when running both caches, should be removed when nuchache is removed")] public class DomainAndUrlsTests : UmbracoIntegrationTest { [SetUp] @@ -66,6 +67,7 @@ public void Setup() protected override void CustomTestSetup(IUmbracoBuilder builder) { builder.Services.AddUnique(_variationContextAccessor); + builder.AddUmbracoHybridCache(); builder.AddNuCache(); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs index 9e34979c6ec4..06e22d935d31 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentPickerValueConverterTests.cs @@ -15,7 +15,7 @@ public class ContentPickerValueConverterTests : PropertyValueConverterTests { private ContentPickerValueConverter CreateValueConverter(IApiContentNameProvider? nameProvider = null) => new ContentPickerValueConverter( - PublishedSnapshotAccessor, + PublishedContentCacheMock.Object, new ApiContentBuilder( nameProvider ?? new ApiContentNameProvider(), CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings()), diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs index 84ee7b084155..081afae7ec5d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs @@ -10,7 +10,6 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Templates; -using Umbraco.Cms.Core.Web; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -24,7 +23,7 @@ public class MarkdownEditorValueConverterTests : PropertyValueConverterTests [TestCase(123, "")] public void MarkdownEditorValueConverter_ConvertsValueToMarkdownString(object inter, string expected) { - var linkParser = new HtmlLocalLinkParser(Mock.Of(), Mock.Of()); + var linkParser = new HtmlLocalLinkParser(Mock.Of()); var urlParser = new HtmlUrlParser(Mock.Of>(), Mock.Of>(), Mock.Of(), Mock.Of()); var valueConverter = new MarkdownEditorValueConverter(linkParser, urlParser); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs index 2f1b05abf776..75a331bd31fd 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs @@ -375,7 +375,7 @@ protected IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPubl internal PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) { - ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); + ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedContentCacheMock.Object, contentBuilder); var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs index 6ef0f74e2f91..67faeaf7bac6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs @@ -29,9 +29,8 @@ public void Returns_Udis_From_LocalLinks()

media

"; - - var umbracoContextAccessor = new TestUmbracoContextAccessor(); - var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of()); + + var parser = new HtmlLocalLinkParser(Mock.Of()); var result = parser.FindUdisFromLocalLinks(input).ToList(); @@ -56,8 +55,7 @@ public void Returns_Udis_From_Legacy_LocalLinks() hello

"; - var umbracoContextAccessor = new TestUmbracoContextAccessor(); - var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of()); + var parser = new HtmlLocalLinkParser(Mock.Of()); var result = parser.FindUdisFromLocalLinks(input).ToList(); @@ -90,8 +88,7 @@ public void Returns_Udis_From_Legacy_And_Current_LocalLinks() media

"; - var umbracoContextAccessor = new TestUmbracoContextAccessor(); - var parser = new HtmlLocalLinkParser(umbracoContextAccessor, Mock.Of()); + var parser = new HtmlLocalLinkParser(Mock.Of()); var result = parser.FindUdisFromLocalLinks(input).ToList(); @@ -204,7 +201,7 @@ public void ParseLocalLinks(string input, string result) mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); - var linkParser = new HtmlLocalLinkParser(umbracoContextAccessor, publishedUrlProvider); + var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); var output = linkParser.EnsureInternalLinks(input); diff --git a/umbraco.sln b/umbraco.sln index 5e26e18d8c1d..e194c7f6f50c 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -188,6 +188,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.Tests.AcceptanceTes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Cms.Api.Management", "src\Umbraco.Cms.Api.Management\Umbraco.Cms.Api.Management.csproj", "{B4929148-3BD9-4589-829D-7C31FFCFF6D7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.PublishedCache.HybridCache", "src\Umbraco.PublishedCache.HybridCache\Umbraco.PublishedCache.HybridCache.csproj", "{CB0B9817-EDBC-4D6D-B4D2-969019C4606D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -362,6 +364,12 @@ Global {B4929148-3BD9-4589-829D-7C31FFCFF6D7}.Release|Any CPU.Build.0 = Release|Any CPU {B4929148-3BD9-4589-829D-7C31FFCFF6D7}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU {B4929148-3BD9-4589-829D-7C31FFCFF6D7}.SkipTests|Any CPU.Build.0 = Debug|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.Release|Any CPU.Build.0 = Release|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.SkipTests|Any CPU.ActiveCfg = Debug|Any CPU + {CB0B9817-EDBC-4D6D-B4D2-969019C4606D}.SkipTests|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE