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 <ronald@barend.se>

* 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 <ronald@barend.se>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
Co-authored-by: nikolajlauridsen <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Nikolaj Geisle
2024-09-10 00:49:18 +09:00
committed by GitHub
parent dcd6f1fbf4
commit 2704d4a34a
102 changed files with 5881 additions and 132 deletions

View File

@@ -12,27 +12,29 @@
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.0-preview.5.24306.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0-preview.5.24306.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0-preview.5.24306.3" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.0-preview.7.24405.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0-preview.7.24405.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0-preview.7.24405.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.SqlServer" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="9.0.0-preview.7.24405.7"/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.0-preview.7.24405.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0-preview.7.24406.2" />
</ItemGroup>
<!-- Umbraco packages -->
<ItemGroup>
@@ -83,7 +85,7 @@
<!-- Dazinator.Extensions.FileProviders brings in a vulnerable version of System.Net.Http -->
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<!-- Examine brings in a vulnerable version of System.Security.Cryptography.Xml -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.0-preview.7.24405.7" />
<!-- Both Dazinator.Extensions.FileProviders and MiniProfiler.AspNetCore.Mvc bring in a vulnerable version of System.Text.RegularExpressions -->
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<!-- Both OpenIddict.AspNetCore, Npoco.SqlServer and Microsoft.EntityFrameworkCore.SqlServer bring in a vulnerable version of Microsoft.IdentityModel.JsonWebTokens -->

View File

@@ -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
}

View File

@@ -35,6 +35,7 @@ public static partial class UmbracoBuilderExtensions
.AddWebServer()
.AddRecurringBackgroundJobs()
.AddNuCache()
.AddUmbracoHybridCache()
.AddDistributedCache()
.AddCoreNotifications()
.AddExamine()

View File

@@ -14,6 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.PublishedCache.HybridCache\Umbraco.PublishedCache.HybridCache.csproj" />
<ProjectReference Include="..\Umbraco.Cms.Api.Common\Umbraco.Cms.Api.Common.csproj" />
<ProjectReference Include="..\Umbraco.Infrastructure\Umbraco.Infrastructure.csproj" />
</ItemGroup>

View File

@@ -17,8 +17,10 @@ public sealed class DomainCacheRefresher : PayloadCacheRefresherBase<DomainCache
IPublishedSnapshotService publishedSnapshotService,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory) =>
: base(appCaches, serializer, eventAggregator, factory)
{
_publishedSnapshotService = publishedSnapshotService;
}
#region Json

View File

@@ -65,6 +65,7 @@ public static partial class Constants
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
{

View File

@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
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 partial class UmbracoBuilderExtensions
.AddUmbracoOptions<ContentDashboardSettings>()
.AddUmbracoOptions<HelpPageSettings>()
.AddUmbracoOptions<DataTypesSettings>()
.AddUmbracoOptions<WebhookSettings>();
.AddUmbracoOptions<WebhookSettings>()
.AddUmbracoOptions<CacheSettings>();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();

View File

@@ -0,0 +1,13 @@
using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Core.Models;
[UmbracoOptions(Constants.Configuration.ConfigCache)]
public class CacheSettings
{
/// <summary>
/// Gets or sets a value for the collection of content type ids to always have in the cache.
/// </summary>
public List<Guid> ContentTypeKeys { get; set; } =
new();
}

View File

@@ -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; }
}

View File

@@ -10,7 +10,7 @@ public interface IPublishedMemberCache
/// </summary>
/// <param name="member"></param>
/// <returns></returns>
IPublishedContent? Get(IMember member);
IPublishedMember? Get(IMember member);
/// <summary>
/// Gets a content type identified by its unique identifier.
@@ -26,4 +26,12 @@ public interface IPublishedMemberCache
/// <returns>The content type, or null.</returns>
/// <remarks>The alias is case-insensitive.</remarks>
IPublishedContentType GetContentType(string alias);
/// <summary>
/// Get an <see cref="IPublishedContent" /> from an <see cref="IMember" />
/// </summary>
/// <param name="key">The key of the member to fetch</param>
/// <param name="preview">Will fetch draft if this is set to true</param>
/// <returns></returns>
Task<IPublishedMember?> GetAsync(IMember member);
}

View File

@@ -196,7 +196,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
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);

View File

@@ -67,7 +67,7 @@ public class ContentPickerPropertyEditor : DataEditor
// 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;

View File

@@ -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.
/// </remarks>
[Obsolete("Caching no longer supports snapshotting")]
Snapshot = 3,
/// <summary>

View File

@@ -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 class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery
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 class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery
return null;
}
content = publishedSnapshot.Content?.GetById(udi.Guid);
content = _publishedContentCache.GetById(udi.Guid);
if (content != null && content.ContentType.ItemType == PublishedItemType.Content)
{
return content;

View File

@@ -0,0 +1,37 @@
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Core.PublishedCache;
public interface ICacheManager
{
/// <summary>
/// Gets the <see cref="IPublishedContentCache" />.
/// </summary>
IPublishedContentCache Content { get; }
/// <summary>
/// Gets the <see cref="IPublishedMediaCache" />.
/// </summary>
IPublishedMediaCache Media { get; }
/// <summary>
/// Gets the <see cref="IPublishedMemberCache" />.
/// </summary>
IPublishedMemberCache Members { get; }
/// <summary>
/// Gets the <see cref="IDomainCache" />.
/// </summary>
IDomainCache Domains { get; }
/// <summary>
/// Gets the elements-level cache.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// </remarks>
IAppCache ElementsCache { get; }
}

View File

@@ -0,0 +1,31 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Routing;
namespace Umbraco.Cms.Core.PublishedCache;
public interface IDomainCacheService
{
/// <summary>
/// Gets all <see cref="Domain" /> in the current domain cache, including any domains that may be referenced by
/// documents that are no longer published.
/// </summary>
/// <param name="includeWildcards"></param>
/// <returns></returns>
IEnumerable<Domain> GetAll(bool includeWildcards);
/// <summary>
/// Gets all assigned <see cref="Domain" /> for specified document, even if it is not published.
/// </summary>
/// <param name="documentId">The document identifier.</param>
/// <param name="includeWildcards">A value indicating whether to consider wildcard domains.</param>
IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false);
/// <summary>
/// Determines whether a document has domains.
/// </summary>
/// <param name="documentId">The document identifier.</param>
/// <param name="includeWildcards">A value indicating whether to consider wildcard domains.</param>
bool HasAssigned(int documentId, bool includeWildcards = false);
void Refresh(DomainCacheRefresher.JsonPayload[] payloads);
}

View File

@@ -32,6 +32,7 @@ public interface IPublishedCache
/// <param name="contentId">The content Udi identifier.</param>
/// <returns>The content, or null.</returns>
/// <remarks>The value of <paramref name="preview" /> overrides defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
IPublishedContent? GetById(bool preview, Udi contentId);
/// <summary>
@@ -56,25 +57,9 @@ public interface IPublishedCache
/// <param name="contentId">The content unique identifier.</param>
/// <returns>The content, or null.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
IPublishedContent? GetById(Udi contentId);
/// <summary>
/// Gets a value indicating whether the cache contains a specified content.
/// </summary>
/// <param name="preview">A value indicating whether to consider unpublished content.</param>
/// <param name="contentId">The content unique identifier.</param>
/// <returns>A value indicating whether to the cache contains the specified content.</returns>
/// <remarks>The value of <paramref name="preview" /> overrides defaults.</remarks>
bool HasById(bool preview, int contentId);
/// <summary>
/// Gets a value indicating whether the cache contains a specified content.
/// </summary>
/// <param name="contentId">The content unique identifier.</param>
/// <returns>A value indicating whether to the cache contains the specified content.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
bool HasById(int contentId);
/// <summary>
/// Gets contents at root.
/// </summary>
@@ -82,6 +67,7 @@ public interface IPublishedCache
/// <param name="culture">A culture.</param>
/// <returns>The contents.</returns>
/// <remarks>The value of <paramref name="preview" /> overrides defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
IEnumerable<IPublishedContent> GetAtRoot(bool preview, string? culture = null);
/// <summary>
@@ -90,6 +76,7 @@ public interface IPublishedCache
/// <param name="culture">A culture.</param>
/// <returns>The contents.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
IEnumerable<IPublishedContent> GetAtRoot(string? culture = null);
/// <summary>
@@ -98,6 +85,7 @@ public interface IPublishedCache
/// <param name="preview">A value indicating whether to consider unpublished content.</param>
/// <returns>A value indicating whether the cache contains published content.</returns>
/// <remarks>The value of <paramref name="preview" /> overrides defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
bool HasContent(bool preview);
/// <summary>
@@ -105,6 +93,7 @@ public interface IPublishedCache
/// </summary>
/// <returns>A value indicating whether the cache contains published content.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
[Obsolete] // FIXME: Remove when replacing nucache
bool HasContent();
/// <summary>
@@ -112,6 +101,7 @@ public interface IPublishedCache
/// </summary>
/// <param name="id">The content type unique identifier.</param>
/// <returns>The content type, or null.</returns>
[Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(int id);
/// <summary>
@@ -120,6 +110,7 @@ public interface IPublishedCache
/// <param name="alias">The content type alias.</param>
/// <returns>The content type, or null.</returns>
/// <remarks>The alias is case-insensitive.</remarks>
[Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(string alias);
/// <summary>
@@ -127,6 +118,7 @@ public interface IPublishedCache
/// </summary>
/// <param name="contentType">The content type.</param>
/// <returns>The contents.</returns>
[Obsolete] // FIXME: Remove when replacing nucache
IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType);
/// <summary>
@@ -134,5 +126,6 @@ public interface IPublishedCache
/// </summary>
/// <param name="key">The content type key.</param>
/// <returns>The content type, or null.</returns>
[Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(Guid key);
}

View File

@@ -4,6 +4,25 @@ namespace Umbraco.Cms.Core.PublishedCache;
public interface IPublishedContentCache : IPublishedCache
{
/// <summary>
/// Gets a content identified by its unique identifier.
/// </summary>
/// <param name="id">The content unique identifier.</param>
/// <param name="preview">A value indicating whether to consider unpublished content.</param>
/// <returns>The content, or null.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false);
/// <summary>
/// Gets a content identified by its unique identifier.
/// </summary>
/// <param name="key">The content unique identifier.</param>
/// <param name="preview">A value indicating whether to consider unpublished content.</param>
/// <returns>The content, or null.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
Task<IPublishedContent?> 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
/// <summary>
/// Gets content identified by a route.
/// </summary>
@@ -24,6 +43,7 @@ public interface IPublishedContentCache : IPublishedCache
/// </para>
/// <para>The value of <paramref name="preview" /> overrides defaults.</para>
/// </remarks>
[Obsolete]
IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null);
/// <summary>
@@ -45,6 +65,7 @@ public interface IPublishedContentCache : IPublishedCache
/// </para>
/// <para>Considers published or unpublished content depending on defaults.</para>
/// </remarks>
[Obsolete]
IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null);
/// <summary>
@@ -62,6 +83,7 @@ public interface IPublishedContentCache : IPublishedCache
/// </para>
/// <para>The value of <paramref name="preview" /> overrides defaults.</para>
/// </remarks>
[Obsolete]
string? GetRouteById(bool preview, int contentId, string? culture = null);
/// <summary>
@@ -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
/// </para>
[Obsolete]
string? GetRouteById(int contentId, string? culture = null);
}

View File

@@ -0,0 +1,47 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.PublishedCache;
public interface IPublishedContentTypeCache
{
/// <summary>
/// Clears the entire cache.
/// </summary>
public void ClearAll();
/// <summary>
/// Clears a cached content type.
/// </summary>
/// <param name="id">An identifier.</param>
public void ClearContentType(int id);
/// <summary>
/// Clears all cached content types referencing a data type.
/// </summary>
/// <param name="id">A data type identifier.</param>
public void ClearDataType(int id);
/// <summary>
/// Gets a published content type.
/// </summary>
/// <param name="itemType">An item type.</param>
/// <param name="key">An key.</param>
/// <returns>The published content type corresponding to the item key.</returns>
public IPublishedContentType Get(PublishedItemType itemType, Guid key);
/// <summary>
/// Gets a published content type.
/// </summary>
/// <param name="itemType">An item type.</param>
/// <param name="alias">An alias.</param>
/// <returns>The published content type corresponding to the item type and alias.</returns>
public IPublishedContentType Get(PublishedItemType itemType, string alias);
/// <summary>
/// Gets a published content type.
/// </summary>
/// <param name="itemType">An item type.</param>
/// <param name="id">An identifier.</param>
/// <returns>The published content type corresponding to the item type and identifier.</returns>
public IPublishedContentType Get(PublishedItemType itemType, int id);
}

View File

@@ -1,5 +1,22 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.PublishedCache;
public interface IPublishedMediaCache : IPublishedCache
{
/// <summary>
/// Gets a content identified by its unique identifier.
/// </summary>
/// <param name="id">The content unique identifier.</param>
/// <returns>The content, or null.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
Task<IPublishedContent?> GetByIdAsync(int id);
/// <summary>
/// Gets a content identified by its unique identifier.
/// </summary>
/// <param name="key">The content unique identifier.</param>
/// <returns>The content, or null.</returns>
/// <remarks>Considers published or unpublished content depending on defaults.</remarks>
Task<IPublishedContent?> GetByKeyAsync(Guid key);
}

View File

@@ -14,6 +14,12 @@ public sealed class InternalPublishedContentCache : PublishedCacheBase, IPublish
{
}
public Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false) => throw new NotImplementedException();
public Task<IPublishedContent?> GetByIdAsync(Guid key, bool preview = false) => throw new NotImplementedException();
public Task<bool> 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 sealed class InternalPublishedContentCache : PublishedCacheBase, IPublish
// public void Add(InternalPublishedContent content) => _content[content.Id] = content.CreateModel(Mock.Of<IPublishedModelFactory>());
public void Clear() => _content.Clear();
public Task<IPublishedContent?> GetByIdAsync(int id) => throw new NotImplementedException();
public Task<IPublishedContent?> GetByKeyAsync(Guid key) => throw new NotImplementedException();
public Task<bool> HasByIdAsync(int id) => throw new NotImplementedException();
}

View File

@@ -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 sealed class HtmlLocalLinkParser
/// <param name="text"></param>
/// <param name="preview"></param>
/// <returns></returns>
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);
/// <summary>
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
@@ -75,11 +54,6 @@ public sealed class HtmlLocalLinkParser
/// <returns></returns>
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)

View File

@@ -31,6 +31,7 @@ public interface IUmbracoContext : IDisposable
/// </summary>
IPublishedSnapshot PublishedSnapshot { get; }
// TODO: Obsolete these, and use cache manager to get
/// <summary>
/// Gets the published content cache.
/// </summary>

View File

@@ -120,6 +120,7 @@ public static partial class NPocoDatabaseExtensions
/// once T1 and T2 have completed. Whereas here, it could contain T1's value.
/// </para>
/// </remarks>
[Obsolete("Use InsertOrUpdateAsync instead")]
public static RecordPersistenceType InsertOrUpdate<T>(this IUmbracoDatabase db, T poco)
where T : class =>
db.InsertOrUpdate(poco, null, null);
@@ -150,7 +151,7 @@ public static partial class NPocoDatabaseExtensions
/// once T1 and T2 have completed. Whereas here, it could contain T1's value.
/// </para>
/// </remarks>
public static RecordPersistenceType InsertOrUpdate<T>(
public static async Task<RecordPersistenceType> InsertOrUpdateAsync<T>(
this IUmbracoDatabase db,
T poco,
string? updateCommand,
@@ -167,7 +168,7 @@ public static partial class NPocoDatabaseExtensions
// try to update
var rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null
? db.Update(poco)
? await db.UpdateAsync(poco)
: db.Update<T>(updateCommand!, updateArgs);
if (rowCount > 0)
{
@@ -182,7 +183,7 @@ public static partial class NPocoDatabaseExtensions
try
{
// try to insert
db.Insert(poco);
await db.InsertAsync(poco);
return RecordPersistenceType.Insert;
}
catch (DbException)
@@ -193,7 +194,7 @@ public static partial class NPocoDatabaseExtensions
// try to update
rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null
? db.Update(poco)
? await db.UpdateAsync(poco)
: db.Update<T>(updateCommand!, updateArgs);
if (rowCount > 0)
{
@@ -209,6 +210,14 @@ public static partial class NPocoDatabaseExtensions
throw new DataException("Record could not be inserted or updated.");
}
public static RecordPersistenceType InsertOrUpdate<T>(
this IUmbracoDatabase db,
T poco,
string? updateCommand,
object? updateArgs)
where T : class =>
db.InsertOrUpdateAsync(poco, updateCommand, updateArgs).GetAwaiter().GetResult();
/// <summary>
/// This will escape single @ symbols for npoco values so it doesn't think it's a parameter
/// </summary>

View File

@@ -60,6 +60,21 @@ namespace Umbraco.Extensions
return sql;
}
/// <summary>
/// Appends a WHERE IN clause to the Sql statement.
/// </summary>
/// <typeparam name="TDto">The type of the Dto.</typeparam>
/// <param name="sql">The Sql statement.</param>
/// <param name="field">An expression specifying the field.</param>
/// <param name="values">The values.</param>
/// <returns>The Sql statement.</returns>
public static Sql<ISqlContext> WhereIn<TDto>(this Sql<ISqlContext> sql, Expression<Func<TDto, object?>> field, IEnumerable? values, string alias)
{
var fieldName = sql.SqlContext.SqlSyntax.GetFieldName(field, alias);
sql.Where(fieldName + " IN (@values)", new { values });
return sql;
}
/// <summary>
/// Appends a WHERE IN clause to the Sql statement.
/// </summary>

View File

@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.PublishedCache;
/// Represents a content type cache.
/// </summary>
/// <remarks>This cache is not snapshotted, so it refreshes any time things change.</remarks>
public class PublishedContentTypeCache : IDisposable
public class PublishedContentTypeCache : IPublishedContentTypeCache
{
private readonly IContentTypeService? _contentTypeService;
private readonly Dictionary<Guid, int> _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<string, IPublishedContentType> _typesByAlias = new();
private readonly Dictionary<int, IPublishedContentType> _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<PublishedContentTypeCache> logger)
{
_contentTypeService = contentTypeService;
@@ -48,13 +50,6 @@ public class PublishedContentTypeCache : IDisposable
_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
/// <summary>
/// Clears all cached content types.
/// </summary>
@@ -175,7 +170,10 @@ public class PublishedContentTypeCache : IDisposable
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 class PublishedContentTypeCache : IDisposable
}
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_lock.Dispose();
}
_disposedValue = true;
}
}
private static string GetAliasKey(PublishedItemType itemType, string alias)
{
string k;

View File

@@ -0,0 +1,27 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.PublishedCache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <inheritdoc />
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; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <summary>
/// Represents everything that is specific to an edited or published content version
/// </summary>
// 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<string, PropertyData[]>? properties, IReadOnlyDictionary<string, CultureVariation>? 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<string, PropertyData[]> Properties { get; }
/// <summary>
/// The collection of language Id to name for the content item
/// </summary>
public IReadOnlyDictionary<string, CultureVariation>? CultureInfos { get; }
}

View File

@@ -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
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <summary>
/// Represents the culture variation information on a content item
/// </summary>
[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; }
}

View File

@@ -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;
/// <summary>
/// Extension methods for <see cref="IUmbracoBuilder" /> for the Umbraco's NuCache
/// </summary>
public static class UmbracoBuilderExtensions
{
/// <summary>
/// Adds Umbraco NuCache dependencies
/// </summary>
public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder)
{
builder.Services.AddHybridCache();
builder.Services.AddSingleton<IDatabaseCacheRepository, DatabaseCacheRepository>();
builder.Services.AddSingleton<IPublishedContentCache, DocumentCache>();
builder.Services.AddSingleton<IPublishedMediaCache, MediaCache>();
builder.Services.AddSingleton<IPublishedMemberCache, MemberCache>();
builder.Services.AddSingleton<IDomainCache, DomainCache>();
builder.Services.AddSingleton<IElementsCache, ElementsDictionaryAppCache>();
builder.Services.AddSingleton<IPublishedContentTypeCache, PublishedContentTypeCache>();
builder.Services.AddSingleton<IDocumentCacheService, DocumentCacheService>();
builder.Services.AddSingleton<IMediaCacheService, MediaCacheService>();
builder.Services.AddSingleton<IMemberCacheService, MemberCacheService>();
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
builder.Services.AddSingleton<ICacheNodeFactory, CacheNodeFactory>();
builder.Services.AddSingleton<ICacheManager, CacheManager>();
builder.Services.AddSingleton<IContentCacheDataSerializerFactory>(s =>
{
IOptions<NuCacheSettings> options = s.GetRequiredService<IOptions<NuCacheSettings>>();
switch (options.Value.NuCacheSerializerType)
{
case NuCacheSerializerType.JSON:
return new JsonContentNestedDataSerializerFactory();
case NuCacheSerializerType.MessagePack:
return ActivatorUtilities.CreateInstance<MsgPackContentNestedDataSerializerFactory>(s);
default:
throw new IndexOutOfRangeException();
}
});
builder.Services.AddSingleton<IPropertyCacheCompressionOptions, NoopPropertyCacheCompressionOptions>();
builder.AddNotificationAsyncHandler<ContentRefreshNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<ContentDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaRefreshNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, SeedingNotificationHandler>();
builder.AddNotificationAsyncHandler<ContentTypeRefreshedNotification, CacheRefreshingNotificationHandler>();
return builder;
}
}

View File

@@ -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<IPublishedContent?> GetByIdAsync(int id, bool preview = false) => await _documentCacheService.GetByIdAsync(id, preview);
public async Task<IPublishedContent?> 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<IPublishedContent> GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(string? culture = null) => throw new NotImplementedException();
public bool HasContent(bool preview) => throw new NotImplementedException();
public bool HasContent() => throw new NotImplementedException();
public IEnumerable<IPublishedContent> 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();
}

View File

@@ -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;
/// <summary>
/// Implements <see cref="IDomainCache" /> for NuCache.
/// </summary>
public class DomainCache : IDomainCache
{
private readonly IDomainCacheService _domainCacheService;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCache" /> class.
/// </summary>
public DomainCache(IDefaultCultureAccessor defaultCultureAccessor, IDomainCacheService domainCacheService)
{
_domainCacheService = domainCacheService;
DefaultCulture = defaultCultureAccessor.DefaultCulture;
}
/// <inheritdoc />
public string DefaultCulture { get; }
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards) => _domainCacheService.GetAll(includeWildcards);
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.GetAssigned(documentId, includeWildcards);
/// <inheritdoc />
public bool HasAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.HasAssigned(documentId, includeWildcards);
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public class ElementsDictionaryAppCache : FastDictionaryAppCache, IElementsCache
{
}

View File

@@ -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<string, PropertyData[]>();
foreach (IProperty prop in content.Properties)
{
var pdatas = new List<PropertyData>();
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<string, CultureVariation>();
// 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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<string, PropertyData[]> 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<string, PropertyData[]> 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;
}
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public interface IElementsCache : IAppCache
{
}

View File

@@ -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<IPublishedContent?> GetByIdAsync(int id) => await _mediaCacheService.GetByIdAsync(id);
public async Task<IPublishedContent?> 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<IPublishedContent> GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(string? culture = null) => throw new NotImplementedException();
public bool HasContent(bool preview) => throw new NotImplementedException();
public bool HasContent() => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType) =>
throw new NotImplementedException();
}

View File

@@ -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<IPublishedMember?> 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);
}

View File

@@ -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<ContentRefreshNotification>,
INotificationAsyncHandler<ContentDeletedNotification>,
INotificationAsyncHandler<MediaRefreshNotification>,
INotificationAsyncHandler<MediaDeletedNotification>,
INotificationAsyncHandler<ContentTypeRefreshedNotification>
{
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<IRelation> parentRelations = _relationService.GetByParent(content)!;
IEnumerable<IRelation> 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;
}
}

View File

@@ -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<UmbracoApplicationStartedNotification>
{
private readonly IDocumentCacheService _documentCacheService;
private readonly CacheSettings _cacheSettings;
public SeedingNotificationHandler(IDocumentCacheService documentCacheService, IOptions<CacheSettings> cacheSettings)
{
_documentCacheService = documentCacheService;
_cacheSettings = cacheSettings.Value;
}
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) => await _documentCacheService.SeedAsync(_cacheSettings.ContentTypeKeys);
}

View File

@@ -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;
}
}

View File

@@ -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<DatabaseCacheRepository> _logger;
private readonly IMediaRepository _mediaRepository;
private readonly IMemberRepository _memberRepository;
private readonly IOptions<NuCacheSettings> _nucacheSettings;
private readonly IShortStringHelper _shortStringHelper;
private readonly UrlSegmentProviderCollection _urlSegmentProviders;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseCacheRepository" /> class.
/// </summary>
public DatabaseCacheRepository(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
ILogger<DatabaseCacheRepository> logger,
IMemberRepository memberRepository,
IDocumentRepository documentRepository,
IMediaRepository mediaRepository,
IShortStringHelper shortStringHelper,
UrlSegmentProviderCollection urlSegmentProviders,
IContentCacheDataSerializerFactory contentCacheDataSerializerFactory,
IOptions<NuCacheSettings> 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);
}
/// <inheritdoc/>
public void Rebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? 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<int>(
$@"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<int>(
@"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<int>(
@"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<ContentCacheNode?> GetContentSourceAsync(int id, bool preview = false)
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.Append(SqlWhereNodeId(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
ContentSourceDto? dto = await Database.FirstOrDefaultAsync<ContentSourceDto>(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<ContentCacheNode> GetContentByContentTypeKey(IEnumerable<Guid> keys)
{
if (keys.Any() is false)
{
yield break;
}
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.InnerJoin<NodeDto>("n")
.On<NodeDto, ContentDto>((n, c) => n.NodeId == c.ContentTypeId, "n", "umbracoContent")
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, keys,"n")
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql);
foreach (ContentSourceDto row in dtos)
{
yield return CreateContentNodeKit(row, serializer, row.Published is false);
}
}
public async Task<ContentCacheNode?> GetMediaSourceAsync(int id)
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.Append(SqlWhereNodeId(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
ContentSourceDto? dto = await Database.FirstOrDefaultAsync<ContentSourceDto>(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<byte>(),
data = dto.Data,
id = dto.NodeId,
published = dto.Published,
});
}
// assumes content tree lock
private void RebuildContentDbCache(IContentCacheDataSerializer serializer, int groupSize, IReadOnlyCollection<int>? 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<IContent> query = SqlContext.Query<IContent>();
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<IContent> descendants =
_documentRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path"));
var items = new List<ContentNuDto>();
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<int>? 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<IMedia> query = SqlContext.Query<IMedia>();
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<IMedia> 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<int>? 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<IMember> query = SqlContext.Query<IMember>();
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<IMember> 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<string, PropertyData[]>();
foreach (IProperty prop in content.Properties)
{
var pdatas = new List<PropertyData>();
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<string, CultureVariation>();
// 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<ISqlContext> SqlContentSourcesSelect(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesSelect,
tsql =>
tsql.Select<NodeDto>(
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<ContentDto>(x => Alias(x.ContentTypeId, "ContentTypeId"))
.AndSelect<DocumentDto>(x => Alias(x.Published, "Published"), x => Alias(x.Edited, "Edited"))
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<DocumentVersionDto>(x => Alias(x.TemplateId, "EditTemplateId"))
.AndSelect<ContentVersionDto>(
"pcver",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PubName"),
x => Alias(x.VersionDate, "PubVersionDate"),
x => Alias(x.UserId, "PubWriterId"))
.AndSelect<DocumentVersionDto>("pdver", x => Alias(x.TemplateId, "PubTemplateId"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.Data, "EditData"))
.AndSelect<ContentNuDto>("nuPub", x => Alias(x.Data, "PubData"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
.AndSelect<ContentNuDto>("nuPub", x => Alias(x.RawData, "PubDataRaw"))
.From<NodeDto>());
Sql<ISqlContext>? 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<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id)
.LeftJoin<ContentVersionDto>(
j =>
j.InnerJoin<DocumentVersionDto>("pdver")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcver", "pdver"),
"pcver")
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver")
.LeftJoin<ContentNuDto>("nuEdit").On<NodeDto, ContentNuDto>(
(left, right) => left.NodeId == right.NodeId && right.Published == false, aliasRight: "nuEdit")
.LeftJoin<ContentNuDto>("nuPub").On<NodeDto, ContentNuDto>(
(left, right) => left.NodeId == right.NodeId && right.Published == true, aliasRight: "nuPub");
return sql;
}
private Sql<ISqlContext> SqlContentSourcesSelectUmbracoNodeJoin(ISqlContext sqlContext)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.SourcesSelectUmbracoNodeJoin, builder =>
builder.InnerJoin<NodeDto>("x")
.On<NodeDto, NodeDto>(
(left, right) => left.NodeId == right.NodeId ||
SqlText<bool>(left.Path, right.Path,
(lp, rp) => $"({lp} LIKE {syntax.GetConcat(rp, "',%'")})"),
aliasRight: "x"));
Sql<ISqlContext> sql = sqlTemplate.Sql();
return sql;
}
private Sql<ISqlContext> SqlWhereNodeId(ISqlContext sqlContext, int id)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.WhereNodeId,
builder =>
builder.Where<NodeDto>(x => x.NodeId == SqlTemplate.Arg<int>("id")));
Sql<ISqlContext> sql = sqlTemplate.Sql(id);
return sql;
}
private Sql<ISqlContext> SqlOrderByLevelIdSortOrder(ISqlContext sqlContext)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.OrderByLevelIdSortOrder, s =>
s.OrderBy<NodeDto>(x => x.Level, x => x.ParentId, x => x.SortOrder));
Sql<ISqlContext> sql = sqlTemplate.Sql();
return sql;
}
private Sql<ISqlContext> SqlObjectTypeNotTrashed(ISqlContext sqlContext, Guid nodeObjectType)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ObjectTypeNotTrashedFilter, s =>
s.Where<NodeDto>(x =>
x.NodeObjectType == SqlTemplate.Arg<Guid?>("nodeObjectType") &&
x.Trashed == SqlTemplate.Arg<bool>("trashed")));
Sql<ISqlContext> sql = sqlTemplate.Sql(nodeObjectType, false);
return sql;
}
/// <summary>
/// Returns a slightly more optimized query to use for the document counting when paging over the content sources
/// </summary>
/// <param name="joins"></param>
/// <returns></returns>
private Sql<ISqlContext> SqlContentSourcesCount(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesCount, tsql =>
tsql.Select<NodeDto>(x => Alias(x.NodeId, "Id"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((left, right) => left.NodeId == right.NodeId));
Sql<ISqlContext>? 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<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id)
.LeftJoin<ContentVersionDto>(
j =>
j.InnerJoin<DocumentVersionDto>("pdver")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published,
"pcver",
"pdver"),
"pcver")
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver");
return sql;
}
private Sql<ISqlContext> SqlMediaSourcesSelect(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.MediaSourcesSelect, tsql =>
tsql.Select<NodeDto>(
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<ContentDto>(x => Alias(x.ContentTypeId, "ContentTypeId"))
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.Data, "EditData"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
.From<NodeDto>());
Sql<ISqlContext>? 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<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.LeftJoin<ContentNuDto>("nuEdit")
.On<NodeDto, ContentNuDto>(
(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<ContentSourceDto> GetContentNodeDtos(Sql<ISqlContext> 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<ContentSourceDto> dtos;
if (_nucacheSettings.Value.UsePagedSqlQuery)
{
// Use a more efficient COUNT query
Sql<ISqlContext>? sqlCountQuery = SqlContentSourcesCount()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document));
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
dtos = Database.QueryPaged<ContentSourceDto>(_nucacheSettings.Value.SqlPageSize, sql, sqlCount);
}
else
{
dtos = Database.Fetch<ContentSourceDto>(sql);
}
return dtos;
}
}

View File

@@ -0,0 +1,57 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence;
internal interface IDatabaseCacheRepository
{
Task DeleteContentItemAsync(int id);
Task<ContentCacheNode?> GetContentSourceAsync(int id, bool preview = false);
Task<ContentCacheNode?> GetMediaSourceAsync(int id);
IEnumerable<ContentCacheNode> GetContentByContentTypeKey(IEnumerable<Guid> keys);
/// <summary>
/// Refreshes the nucache database row for the given cache node />
/// </summary>
/// <returns><placeholder>A <see cref="Task"/> representing the asynchronous operation.</placeholder></returns>
Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState);
/// <summary>
/// Refreshes the nucache database row for the given cache node />
/// </summary>
/// <returns><placeholder>A <see cref="Task"/> representing the asynchronous operation.</placeholder></returns>
Task RefreshMediaAsync(ContentCacheNode contentCacheNode);
/// <summary>
/// Rebuilds the caches for content, media and/or members based on the content type ids specified
/// </summary>
/// <param name="contentTypeIds">
/// If not null will process content for the matching content types, if empty will process all
/// content
/// </param>
/// <param name="mediaTypeIds">
/// If not null will process content for the matching media types, if empty will process all
/// media
/// </param>
/// <param name="memberTypeIds">
/// If not null will process content for the matching members types, if empty will process all
/// members
/// </param>
void Rebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null);
/// <summary>
/// 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
/// </summary>
bool VerifyContentDbCache();
/// <summary>
/// Rebuilds the caches for content, media and/or members based on the content type ids specified
/// </summary>
bool VerifyMediaDbCache();
}

View File

@@ -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; }
}

View File

@@ -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<string, PublishedCultureInfo>? _cultures;
private readonly string? _urlSegment;
private readonly IReadOnlyDictionary<string, CultureVariation>? _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<IPublishedProperty> 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<IPublishedContent> ChildrenForAllCultures { get; } = Enumerable.Empty<IPublishedContent>();
public override IPublishedContent? Parent { get; } = null!;
/// <inheritdoc />
public override IReadOnlyDictionary<string, PublishedCultureInfo> Cultures
{
get
{
if (_cultures != null)
{
return _cultures;
}
if (!ContentType.VariesByCulture())
{
return _cultures = new Dictionary<string, PublishedCultureInfo>
{
{ 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);
}
}
/// <inheritdoc/>
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);
}
}

View File

@@ -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;
}

View File

@@ -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<CompositeStringStringKey, SourceInterValue>? _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<object?> getValue)
{
if (cacheValues.DeliveryApiDefaultObjectInitialized == false)
{
cacheValues.DeliveryApiDefaultObjectValue = getValue();
cacheValues.DeliveryApiDefaultObjectInitialized = true;
}
return cacheValues.DeliveryApiDefaultObjectValue;
}
private object? GetDeliveryApiExpandedObject(CacheValue cacheValues, Func<object?> 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<CompositeStringStringKey, CacheValue>? _values;
public CacheValue For(string? culture, string? segment)
{
if (culture == string.Empty && segment == string.Empty)
{
return this;
}
if (_values == null)
{
lock (_locko)
{
_values ??= InitializeConcurrentDictionary<CompositeStringStringKey, CacheValue>();
}
}
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<TKey, TValue> InitializeConcurrentDictionary<TKey, TValue>()
where TKey : notnull
=> new(-1, 5);
private void EnsureSourceValuesInitialized()
{
if (_sourceValues is not null)
{
return;
}
lock (_locko)
{
_sourceValues ??= InitializeConcurrentDictionary<CompositeStringStringKey, SourceInterValue>();
}
}
}

View File

@@ -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;
/// <summary>
/// The content model stored in the content cache database table serialized as JSON
/// </summary>
[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<PropertyData[]>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<PropertyData[]>))]
public Dictionary<string, PropertyData[]>? PropertyData { get; set; }
[DataMember(Order = 1)]
[JsonPropertyName("cd")]
[JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter<CultureVariation>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<CultureVariation>))]
public Dictionary<string, CultureVariation>? CultureData { get; set; }
// TODO: Remove this when routing cache is in place
[DataMember(Order = 2)]
[JsonPropertyName("us")]
public string? UrlSegment { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// The serialization result from <see cref="IContentCacheDataSerializer" /> for which the serialized value
/// will be either a string or a byte[]
/// </summary>
public struct ContentCacheDataSerializationResult : IEquatable<ContentCacheDataSerializationResult>
{
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<byte[]>.Default.Equals(ByteData, other.ByteData);
public override int GetHashCode()
{
var hashCode = 1910544615;
if (StringData is not null)
{
hashCode = (hashCode * -1521134295) + EqualityComparer<string>.Default.GetHashCode(StringData);
}
if (ByteData is not null)
{
hashCode = (hashCode * -1521134295) + EqualityComparer<byte[]>.Default.GetHashCode(ByteData);
}
return hashCode;
}
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
[Flags]
public enum ContentCacheDataSerializerEntityType
{
Document = 1,
Media = 2,
Member = 4,
}

View File

@@ -0,0 +1,22 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string
/// </summary>
/// <remarks>
/// Resolved from the <see cref="IContentCacheDataSerializerFactory" />. This cannot be resolved from DI.
/// </remarks>
internal interface IContentCacheDataSerializer
{
/// <summary>
/// Deserialize the data into a <see cref="ContentCacheDataModel" />
/// </summary>
ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published);
/// <summary>
/// Serializes the <see cref="ContentCacheDataModel" />
/// </summary>
ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published);
}

View File

@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal interface IContentCacheDataSerializerFactory
{
/// <summary>
/// Gets or creates a new instance of <see cref="IContentCacheDataSerializer" />
/// </summary>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types);
}

View File

@@ -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
};
/// <inheritdoc />
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<ContentCacheDataModel>(stringData!, _jsonSerializerOptions);
}
/// <inheritdoc />
public ContentCacheDataSerializationResult Serialize(
IReadOnlyContentBase content,
ContentCacheDataModel model,
bool published)
{
var json = JsonSerializer.Serialize(model, _jsonSerializerOptions);
return new ContentCacheDataSerializationResult(json, null);
}
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal class JsonContentNestedDataSerializerFactory : IContentCacheDataSerializerFactory
{
private readonly Lazy<JsonContentNestedDataSerializer> _serializer = new();
public IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types) => _serializer.Value;
}

View File

@@ -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;
/// <summary>
/// Lazily decompresses a LZ4 Pickler compressed UTF8 string
/// </summary>
[DebuggerDisplay("{Display}")]
internal struct LazyCompressedString
{
private readonly object _locker;
private byte[]? _bytes;
private string? _str;
/// <summary>
/// Constructor
/// </summary>
/// <param name="bytes">LZ4 Pickle compressed UTF8 String</param>
public LazyCompressedString(byte[] bytes)
{
_locker = new object();
_bytes = bytes;
_str = null;
}
/// <summary>
/// Used to display debugging output since ToString() can only be called once
/// </summary>
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;
}
/// <summary>
/// Returns the decompressed string from the bytes. This methods can only be called once.
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Throws if this is called more than once</exception>
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();
}

View File

@@ -0,0 +1,27 @@
using MessagePack;
using MessagePack.Formatters;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// A MessagePack formatter (deserializer) for a string key dictionary that uses <see cref="StringComparer.OrdinalIgnoreCase" /> for the key string comparison and interns the string.
/// </summary>
/// <typeparam name="TValue">The type of the value.</typeparam>
public sealed class MessagePackDictionaryStringInternIgnoreCaseFormatter<TValue> : DictionaryFormatterBase<string, TValue, Dictionary<string, TValue>, Dictionary<string, TValue>.Enumerator, Dictionary<string, TValue>>
{
/// <inheritdoc />
protected override void Add(Dictionary<string, TValue> collection, int index, string key, TValue value, MessagePackSerializerOptions options)
=> collection.Add(string.Intern(key), value);
/// <inheritdoc />
protected override Dictionary<string, TValue> Complete(Dictionary<string, TValue> intermediateCollection)
=> intermediateCollection;
/// <inheritdoc />
protected override Dictionary<string, TValue>.Enumerator GetSourceEnumerator(Dictionary<string, TValue> source)
=> source.GetEnumerator();
/// <inheritdoc />
protected override Dictionary<string, TValue> Create(int count, MessagePackSerializerOptions options)
=> new(count, StringComparer.OrdinalIgnoreCase);
}

View File

@@ -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;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as bytes using
/// MessagePack
/// </summary>
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<ContentCacheDataModel>(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<ContentCacheDataModel>(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;
}
/// <summary>
/// Used during serialization to compress properties
/// </summary>
/// <param name="content"></param>
/// <param name="model"></param>
/// <param name="published"></param>
/// <remarks>
/// 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.
/// </remarks>
private void Compress(IReadOnlyContentBase content, ContentCacheDataModel model, bool published)
{
if (model.PropertyData is null)
{
return;
}
foreach (KeyValuePair<string, PropertyData[]> 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);
}
}
}
}
/// <summary>
/// Used during deserialization to map the property data as lazy or expand the value
/// </summary>
/// <param name="content"></param>
/// <param name="nestedData"></param>
/// <param name="published"></param>
private void Expand(IReadOnlyContentBase content, ContentCacheDataModel nestedData, bool published)
{
if (nestedData.PropertyData is null)
{
return;
}
foreach (KeyValuePair<string, PropertyData[]> 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);
}
}
}
}
}
}

View File

@@ -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<int, IContentTypeComposition>();
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;
}
}

View File

@@ -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<IPublishedContent?> GetByKeyAsync(Guid key, bool preview = false)
{
Attempt<int> 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<IPublishedContent?> GetByIdAsync(int id, bool preview = false)
{
Attempt<Guid> 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<Guid> contentTypeKeys)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
IEnumerable<ContentCacheNode> 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<bool> HasContentByIdAsync(int id, bool preview = false)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document);
if (keyAttempt.Success is false)
{
return false;
}
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync<ContentCacheNode?>(
GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry
cancel => ValueTask.FromResult<ContentCacheNode?>(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<Guid> 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<int> contentTypeKeys)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
_databaseCacheRepository.Rebuild(contentTypeKeys.ToList());
IEnumerable<ContentCacheNode> 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();
}
}

View File

@@ -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<int, Domain> _domains;
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
public IEnumerable<Domain> 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);
}
/// <inheritdoc />
public IEnumerable<Domain> 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<Domain> 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);
}
/// <inheritdoc />
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<IDomain> 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);
}
}
}

View File

@@ -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<IPublishedContent?> GetByKeyAsync(Guid key, bool preview = false);
Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false);
Task SeedAsync(IReadOnlyCollection<Guid> contentTypeKeys);
Task<bool> HasContentByIdAsync(int id, bool preview = false);
Task RefreshContentAsync(IContent content);
Task DeleteItemAsync(int id);
void Rebuild(IReadOnlyCollection<int> contentTypeKeys);
}

View File

@@ -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<IPublishedContent?> GetByKeyAsync(Guid key);
Task<IPublishedContent?> GetByIdAsync(int id);
Task<bool> HasContentByIdAsync(int id);
Task RefreshMediaAsync(IMedia media);
Task DeleteItemAsync(int id);
}

View File

@@ -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<IPublishedMember?> Get(IMember member);
}

View File

@@ -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<IPublishedContent?> GetByKeyAsync(Guid key)
{
Attempt<int> 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<IPublishedContent?> GetByIdAsync(int id)
{
Attempt<Guid> 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<bool> HasContentByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
if (keyAttempt.Success is false)
{
return false;
}
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync<ContentCacheNode?>(
$"{keyAttempt.Result}", // Unique key to the cache entry
cancel => ValueTask.FromResult<ContentCacheNode?>(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<Guid> 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}";
}

View File

@@ -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<IPublishedMember?> Get(IMember member) => member is null ? null : _publishedContentFactory.ToPublishedMember(member);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Umbraco.Cms.PublishedCache.HybridCache</PackageId>
<Title>Umbraco CMS - Published cache - HybridCache</Title>
<Description>Contains the published cache assembly needed to run Umbraco CMS.</Description>
<RootNamespace>Umbraco.Cms.Infrastructure.HybridCache</RootNamespace>
<!-- TODO: Enable package validation in v16 by removing this line -->
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
<PackageReference Include="MessagePack" />
<PackageReference Include="K4os.Compression.LZ4" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Core\Umbraco.Core.csproj" />
<ProjectReference Include="..\Umbraco.Infrastructure\Umbraco.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -56,6 +56,12 @@ public class ContentCache : PublishedCacheBase, IPublishedContentCache, INavigab
public IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null) =>
GetByRoute(PreviewDefault, route, hideTopLevelNode, culture);
public Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false) => throw new NotImplementedException();
public Task<IPublishedContent?> GetByIdAsync(Guid key, bool preview = false) => throw new NotImplementedException();
public Task<bool> 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)

View File

@@ -99,4 +99,10 @@ public class MediaCache : PublishedCacheBase, IPublishedMediaCache, INavigableDa
public override IPublishedContentType? GetContentType(Guid key) => _snapshot.GetContentType(key);
#endregion
public Task<IPublishedContent?> GetByIdAsync(int id) => throw new NotImplementedException();
public Task<IPublishedContent?> GetByKeyAsync(Guid key) => throw new NotImplementedException();
public Task<bool> HasByIdAsync(int id) => throw new NotImplementedException();
}

View File

@@ -32,10 +32,11 @@ public class MemberCache : IPublishedMemberCache, IDisposable
public IPublishedContentType GetContentType(int id) => _contentTypeCache.Get(PublishedItemType.Member, id);
public IPublishedContentType GetContentType(string alias) => _contentTypeCache.Get(PublishedItemType.Member, alias);
public Task<IPublishedMember?> 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 @@ public class MemberCache : IPublishedMemberCache, IDisposable
{
if (disposing)
{
_contentTypeCache.Dispose();
// _contentTypeCache.Dispose();
}
_disposedValue = true;

View File

@@ -337,6 +337,7 @@ AND cmsContentNu.nodeId IS NULL
Sql<ISqlContext>? sql = SqlMediaSourcesSelect(SqlContentSourcesSelectUmbracoNodeJoin)
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.Append(SqlWhereNodeIdX(SqlContext, id))
.Append(SqlWhereNodeIdX(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
IContentCacheDataSerializer serializer =

View File

@@ -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,

View File

@@ -100,6 +100,7 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer
}
// 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

View File

@@ -5,16 +5,16 @@
<ItemGroup>
<!-- Microsoft packages -->
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.7.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageVersion Include="System.Data.DataSetExtensions" Version="4.5.0" />
<PackageVersion Include="System.Data.Odbc" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="System.Data.OleDb" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.5.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="System.Data.Odbc" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="System.Data.OleDb" Version="9.0.0-preview.5.24306.7" />
<PackageVersion Include="System.Data.Odbc" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="System.Data.OleDb" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.7.24306.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="System.Data.Odbc" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="System.Data.OleDb" Version="9.0.0-preview.7.24306.7" />
<PackageVersion Include="System.Reflection.Emit" Version="4.7.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -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<ContentCreateModel>,
IWithInvariantNameBuilder,
IWithInvariantPropertiesBuilder,
IWithVariantsBuilder,
IWithKeyBuilder,
IWithContentTypeKeyBuilder,
IWithParentKeyBuilder,
IWithTemplateKeyBuilder
{
private IContentType _contentType;
private ContentTypeBuilder _contentTypeBuilder;
private IEnumerable<PropertyValueModel> _invariantProperties = [];
private IEnumerable<VariantModel> _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<PropertyValueModel> IWithInvariantPropertiesBuilder.InvariantProperties
{
get => _invariantProperties;
set => _invariantProperties = value;
}
IEnumerable<VariantModel> 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<PropertyValueModel> 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();
}

View File

@@ -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<T>(this T Builder, string invariantName)
where T : IWithInvariantNameBuilder
{
Builder.InvariantName = invariantName;
return Builder;
}
public static T WithInvariantProperties<T>(this T Builder, IEnumerable<PropertyValueModel> invariantProperties)
where T : IWithInvariantPropertiesBuilder
{
Builder.InvariantProperties = invariantProperties;
return Builder;
}
public static T WithVariants<T>(this T Builder, IEnumerable<VariantModel> variants)
where T : IWithVariantsBuilder
{
Builder.Variants = variants;
return Builder;
}
public static T WithKey<T>(this T Builder, Guid? key)
where T : IWithKeyBuilder
{
Builder.Key = key;
return Builder;
}
public static T WithContentTypeKey<T>(this T Builder, Guid contentTypeKey)
where T : IWithContentTypeKeyBuilder
{
Builder.ContentTypeKey = contentTypeKey;
return Builder;
}
public static T WithParentKey<T>(this T Builder, Guid? parentKey)
where T : IWithParentKeyBuilder
{
Builder.ParentKey = parentKey;
return Builder;
}
public static T WithTemplateKey<T>(this T Builder, Guid? templateKey)
where T : IWithTemplateKeyBuilder
{
Builder.TemplateKey = templateKey;
return Builder;
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel;
public interface IWithContentTypeKeyBuilder
{
public Guid ContentTypeKey { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel;
public interface IWithInvariantNameBuilder
{
public string? InvariantName { get; set; }
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel;
public interface IWithInvariantPropertiesBuilder
{
public IEnumerable<PropertyValueModel> InvariantProperties { get; set; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel;
public interface IWithTemplateKeyBuilder
{
public Guid? TemplateKey { get; set; }
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Tests.Common.Builders.Interfaces.ContentCreateModel;
public interface IWithVariantsBuilder
{
public IEnumerable<VariantModel> Variants { get; set; }
}

View File

@@ -257,6 +257,7 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest
.AddUmbracoCore()
.AddWebComponents()
.AddNuCache()
.AddUmbracoHybridCache()
.AddBackOfficeCore()
.AddBackOfficeAuthentication()
.AddBackOfficeIdentity()

View File

@@ -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<IContentTypeService>();
protected ITemplateService TemplateService => GetRequiredService<ITemplateService>();
private ContentEditingService ContentEditingService =>
(ContentEditingService)GetRequiredService<IContentEditingService>();
private ContentPublishingService ContentPublishingService =>
(ContentPublishingService)GetRequiredService<IContentPublishingService>();
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<string> { "*" }, 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;
}
}
}

View File

@@ -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<IPublishedContentCache>();
private IPublishedContentTypeCache PublishedContentTypeCache => GetRequiredService<IPublishedContentTypeCache>();
[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));
}
}

View File

@@ -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<IDatabaseCacheRepository> _mockedNucacheRepository;
private IDocumentCacheService _mockDocumentCacheService;
protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache();
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
[SetUp]
public void SetUp()
{
_mockedNucacheRepository = new Mock<IDatabaseCacheRepository>();
var contentData = new ContentData(
Textpage.Name,
null,
1,
Textpage.UpdateDate,
Textpage.CreatorId,
-1,
false,
new Dictionary<string, PropertyData[]>(),
null);
_mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny<int>(), It.IsAny<bool>())).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<IReadOnlyCollection<Guid>>())).Returns(
new List<ContentCacheNode>()
{
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<int>()));
_mockDocumentCacheService = new DocumentCacheService(
_mockedNucacheRepository.Object,
GetRequiredService<IIdKeyMap>(),
GetRequiredService<ICoreScopeProvider>(),
GetRequiredService<Microsoft.Extensions.Caching.Hybrid.HybridCache>(),
GetRequiredService<IPublishedContentFactory>(),
GetRequiredService<ICacheNodeFactory>());
_mockedCache = new DocumentCache(_mockDocumentCacheService, GetRequiredService<IPublishedContentTypeCache>());
}
[Test]
public async Task Content_Is_Cached_By_Key()
{
var hybridCache = GetRequiredService<Microsoft.Extensions.Caching.Hybrid.HybridCache>();
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<int>(), It.IsAny<bool>()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Cached_By_Id()
{
var hybridCache = GetRequiredService<Microsoft.Extensions.Caching.Hybrid.HybridCache>();
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<int>(), It.IsAny<bool>()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Seeded_By_Id()
{
var schedule = new CultureAndScheduleModel
{
CulturesToPublishImmediately = new HashSet<string> { "*" }, 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<int>(), It.IsAny<bool>()), Times.Exactly(0));
}
[Test]
public async Task Content_Is_Seeded_By_Key()
{
var schedule = new CultureAndScheduleModel
{
CulturesToPublishImmediately = new HashSet<string> { "*" }, 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<int>(), It.IsAny<bool>()), 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<int>(), It.IsAny<bool>()), 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<int>(), It.IsAny<bool>()), 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<IPublishedProperty> 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);
});
}
}

View File

@@ -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<ICacheManager>();
private ITemplateService TemplateService => GetRequiredService<ITemplateService>();
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IContentEditingService ContentEditingService => GetRequiredService<IContentEditingService>();
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
[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<string> {"*"},
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<IContent> 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<string> {"*"},
Schedules = new ContentScheduleCollection(),
},
Constants.Security.SuperUserKey);
return result.Result.Content;
}
private async Task<IContent> 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<string> {"*"},
Schedules = new ContentScheduleCollection(),
},
Constants.Security.SuperUserKey);
Assert.IsTrue(publishResult.Success);
return createResult.Result.Content;
}
}

View File

@@ -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<IPublishedContentCache>();
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
private ICoreScopeProvider ICoreScopeProvider => GetRequiredService<ICoreScopeProvider>();
[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);
}
}

View File

@@ -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<IPublishedContentCache>();
private IContentEditingService ContentEditingService => GetRequiredService<IContentEditingService>();
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
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<PropertyValueModel> propertyCollection, IEnumerable<IPublishedProperty> 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());
});
}
}

View File

@@ -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<IContentTypeService>();
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();
private IContentEditingService ContentEditingService => GetRequiredService<IContentEditingService>();
private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService<IUmbracoContextFactory>();
private IPublishedContentCache PublishedContentHybridCache => GetRequiredService<IPublishedContentCache>();
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;
}
}

View File

@@ -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<IPublishedMediaCache>();
private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService<IUmbracoContextFactory>();
private IMediaTypeService MediaTypeService => GetRequiredService<IMediaTypeService>();
private IMediaEditingService MediaEditingService => GetRequiredService<IMediaEditingService>();
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<PropertyValueModel>()
{
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<IMedia> 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<PropertyValueModel>()
{
new()
{
Alias = "title",
Value = "NewTitle"
}
}
};
var result = await MediaEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
return result.Result.Content;
}
}

View File

@@ -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<IPublishedMemberCache>();
private IMemberEditingService MemberEditingService => GetRequiredService<IMemberEditingService>();
private IMemberService MemberService => GetRequiredService<IMemberService>();
private IMemberTypeService MemberTypeService => GetRequiredService<IMemberTypeService>();
private IMemberGroupService MemberGroupService => GetRequiredService<IMemberGroupService>();
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<IMember> 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<IUserService>().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult();
}

View File

@@ -25,6 +25,7 @@
<ProjectReference Include="..\..\src\Umbraco.Cms.Api.Management\Umbraco.Cms.Api.Management.csproj" />
<ProjectReference Include="..\..\src\Umbraco.Cms.Persistence.EFCore\Umbraco.Cms.Persistence.EFCore.csproj" />
<ProjectReference Include="..\..\src\Umbraco.Cms\Umbraco.Cms.csproj" />
<ProjectReference Include="..\..\src\Umbraco.PublishedCache.HybridCache\Umbraco.PublishedCache.HybridCache.csproj" />
<ProjectReference Include="..\Umbraco.Tests.Common\Umbraco.Tests.Common.csproj" />
</ItemGroup>

View File

@@ -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 class DomainAndUrlsTests : UmbracoIntegrationTest
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.Services.AddUnique<IVariationContextAccessor>(_variationContextAccessor);
builder.AddUmbracoHybridCache();
builder.AddNuCache();
}

View File

@@ -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()),

View File

@@ -10,7 +10,6 @@ using Umbraco.Cms.Core.PropertyEditors;
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<IUmbracoContextAccessor>(), Mock.Of<IPublishedUrlProvider>());
var linkParser = new HtmlLocalLinkParser(Mock.Of<IPublishedUrlProvider>());
var urlParser = new HtmlUrlParser(Mock.Of<IOptionsMonitor<ContentSettings>>(), Mock.Of<ILogger<HtmlUrlParser>>(), Mock.Of<IProfilingLogger>(), Mock.Of<IIOHelper>());
var valueConverter = new MarkdownEditorValueConverter(linkParser, urlParser);

View File

@@ -375,7 +375,7 @@ public abstract class OutputExpansionStrategyTestBase : PropertyValueConverterTe
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());

Some files were not shown because too many files have changed in this diff Show More