Content search abstractions to facilitate new search in the backoffice (#19046)

This commit is contained in:
Kenn Jacobsen
2025-04-15 16:18:42 +02:00
committed by GitHub
parent fcc8e2fe3a
commit 96c0509719
11 changed files with 325 additions and 88 deletions

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IContentSearchService : IContentSearchService<IContent>
{
}

View File

@@ -0,0 +1,14 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IContentSearchService<TContent>
where TContent : class, IContentBase
{
Task<PagedModel<TContent>> SearchChildrenAsync(
string? query,
Guid? parentId,
Ordering? ordering,
int skip = 0,
int take = 100);
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IMediaSearchService : IContentSearchService<IMedia>
{
}

View File

@@ -81,6 +81,8 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IContentTypeSearchService, ContentTypeSearchService>();
builder.Services.AddUnique<IIndexedEntitySearchService, IndexedEntitySearchService>();
builder.Services.TryAddTransient<IReservedFieldNamesService, ReservedFieldNamesService>();
builder.Services.AddUnique<IContentSearchService, ContentSearchService>();
builder.Services.AddUnique<IMediaSearchService, MediaSearchService>();
return builder;
}

View File

@@ -1,11 +1,9 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Services;
@@ -17,24 +15,17 @@ internal abstract class ContentListViewServiceBase<TContent, TContentType, TCont
{
private readonly TContentTypeService _contentTypeService;
private readonly IDataTypeService _dataTypeService;
private readonly ISqlContext _sqlContext;
private readonly IContentSearchService<TContent> _contentSearchService;
protected ContentListViewServiceBase(TContentTypeService contentTypeService, IDataTypeService dataTypeService, ISqlContext sqlContext)
protected ContentListViewServiceBase(TContentTypeService contentTypeService, IDataTypeService dataTypeService, IContentSearchService<TContent> contentSearchService)
{
_contentTypeService = contentTypeService;
_dataTypeService = dataTypeService;
_sqlContext = sqlContext;
_contentSearchService = contentSearchService;
}
protected abstract Guid DefaultListViewKey { get; }
protected abstract Task<PagedModel<TContent>> GetPagedChildrenAsync(
int id,
IQuery<TContent>? filter,
Ordering? ordering,
int skip,
int take);
protected abstract Task<bool> HasAccessToListViewItemAsync(IUser user, Guid key);
protected async Task<Attempt<ListViewPagedModel<TContent>?, ContentCollectionOperationStatus>> GetListViewResultAsync(
@@ -62,7 +53,7 @@ internal abstract class ContentListViewServiceBase<TContent, TContentType, TCont
return Attempt.FailWithStatus<ListViewPagedModel<TContent>?, ContentCollectionOperationStatus>(orderingAttempt.Status, null);
}
PagedModel<TContent> items = await GetAllowedListViewItemsAsync(user, content?.Id ?? Constants.System.Root, filter, orderingAttempt.Result, skip, take);
PagedModel<TContent> items = await GetAllowedListViewItemsAsync(user, content?.Key, filter, orderingAttempt.Result, skip, take);
var result = new ListViewPagedModel<TContent>
{
@@ -216,20 +207,13 @@ internal abstract class ContentListViewServiceBase<TContent, TContentType, TCont
return await _dataTypeService.GetAsync(configuredListViewKey);
}
private async Task<PagedModel<TContent>> GetAllowedListViewItemsAsync(IUser user, int contentId, string? filter, Ordering? ordering, int skip, int take)
private async Task<PagedModel<TContent>> GetAllowedListViewItemsAsync(IUser user, Guid? contentId, string? filter, Ordering? ordering, int skip, int take)
{
var queryFilter = ParseQueryFilter(filter);
var pagedChildren = await GetPagedChildrenAsync(
contentId,
queryFilter,
ordering,
skip,
take);
PagedModel<TContent> pagedChildren = await _contentSearchService.SearchChildrenAsync(filter, contentId, ordering, skip, take);
// Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off.
// This has been the case with this functionality in Umbraco for a long time.
var items = await FilterItemsBasedOnAccessAsync(user, pagedChildren.Items);
IEnumerable<TContent> items = await FilterItemsBasedOnAccessAsync(user, pagedChildren.Items);
var pagedResult = new PagedModel<TContent>
{
@@ -240,18 +224,6 @@ internal abstract class ContentListViewServiceBase<TContent, TContentType, TCont
return pagedResult;
}
private IQuery<TContent>? ParseQueryFilter(string? filter)
{
// Adding multiple conditions - considering key (as Guid) & name as filter param
Guid.TryParse(filter, out Guid filterAsGuid);
return filter.IsNullOrWhiteSpace()
? null
: _sqlContext.Query<TContent>()
.Where(c => (c.Name != null && c.Name.Contains(filter)) ||
c.Key == filterAsGuid);
}
// TODO: Optimize the way we filter out only the nodes the user is allowed to see - instead of checking one by one
private async Task<IEnumerable<TContent>> FilterItemsBasedOnAccessAsync(IUser user, IEnumerable<TContent> items)
{

View File

@@ -21,9 +21,9 @@ internal sealed class ContentListViewService : ContentListViewServiceBase<IConte
IContentService contentService,
IContentTypeService contentTypeService,
IDataTypeService dataTypeService,
ISqlContext sqlContext,
IContentSearchService contentSearchService,
IContentPermissionAuthorizer contentPermissionAuthorizer)
: base(contentTypeService, dataTypeService, sqlContext)
: base(contentTypeService, dataTypeService, contentSearchService)
{
_contentService = contentService;
_contentPermissionAuthorizer = contentPermissionAuthorizer;
@@ -49,32 +49,6 @@ internal sealed class ContentListViewService : ContentListViewServiceBase<IConte
return await GetListViewResultAsync(user, content, dataTypeKey, orderBy, orderCulture, orderDirection, filter, skip, take);
}
protected override Task<PagedModel<IContent>> GetPagedChildrenAsync(
int id,
IQuery<IContent>? filter,
Ordering? ordering,
int skip,
int take)
{
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize);
IEnumerable<IContent> items = _contentService.GetPagedChildren(
id,
pageNumber,
pageSize,
out var total,
filter,
ordering);
var pagedResult = new PagedModel<IContent>
{
Items = items,
Total = total,
};
return Task.FromResult(pagedResult);
}
// We can use an authorizer here, as it already handles all the necessary checks for this filtering.
// However, we cannot pass in all the items; we want only the ones that comply, as opposed to
// a general response whether the user has access to all nodes.

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence;
namespace Umbraco.Cms.Infrastructure.Services.Implement;
internal sealed class ContentSearchService : ContentSearchServiceBase<IContent>, IContentSearchService
{
private readonly IContentService _contentService;
public ContentSearchService(ISqlContext sqlContext, IIdKeyMap idKeyMap, ILogger<ContentSearchService> logger, IContentService contentService)
: base(sqlContext, idKeyMap, logger)
=> _contentService = contentService;
protected override UmbracoObjectTypes ObjectType => UmbracoObjectTypes.Document;
protected override Task<IEnumerable<IContent>> SearchChildrenAsync(
IQuery<IContent>? query,
int parentId,
Ordering? ordering,
long pageNumber,
int pageSize,
out long total)
=> Task.FromResult(_contentService.GetPagedChildren(
parentId,
pageNumber,
pageSize,
out total,
query,
ordering));
}

View File

@@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Services.Implement;
internal abstract class ContentSearchServiceBase<TContent> : IContentSearchService<TContent>
where TContent : class, IContentBase
{
private readonly ISqlContext _sqlContext;
private readonly IIdKeyMap _idKeyMap;
private readonly ILogger<ContentSearchServiceBase<TContent>> _logger;
protected ContentSearchServiceBase(ISqlContext sqlContext, IIdKeyMap idKeyMap, ILogger<ContentSearchServiceBase<TContent>> logger)
{
_sqlContext = sqlContext;
_idKeyMap = idKeyMap;
_logger = logger;
}
protected abstract UmbracoObjectTypes ObjectType { get; }
protected abstract Task<IEnumerable<TContent>> SearchChildrenAsync(
IQuery<TContent>? query,
int parentId,
Ordering? ordering,
long pageNumber,
int pageSize,
out long total);
public async Task<PagedModel<TContent>> SearchChildrenAsync(
string? query,
Guid? parentId,
Ordering? ordering,
int skip = 0,
int take = 100)
{
var parentIdAsInt = Constants.System.Root;
if (parentId.HasValue)
{
Attempt<int> keyToId = _idKeyMap.GetIdForKey(parentId.Value, ObjectType);
if (keyToId.Success is false)
{
_logger.LogWarning("Could not obtain an ID for parent key: {parentId} (object type: {contentType}", parentId, typeof(TContent).FullName);
return new PagedModel<TContent>(0, []);
}
parentIdAsInt = keyToId.Result;
}
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize);
IQuery<TContent>? contentQuery = ParseQuery(query);
IEnumerable<TContent> items = await SearchChildrenAsync(contentQuery, parentIdAsInt, ordering, pageNumber, pageSize, out var total);
return new PagedModel<TContent>
{
Items = items,
Total = total,
};
}
private IQuery<TContent>? ParseQuery(string? query)
{
// Adding multiple conditions - considering key (as Guid) & name as filter param
Guid.TryParse(query, out Guid filterAsGuid);
return query.IsNullOrWhiteSpace()
? null
: _sqlContext
.Query<TContent>()
.Where(c => (c.Name != null && c.Name.Contains(query)) || c.Key == filterAsGuid);
}
}

View File

@@ -1,11 +1,9 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Infrastructure.Persistence;
namespace Umbraco.Cms.Infrastructure.Services.Implement;
@@ -20,9 +18,9 @@ internal sealed class MediaListViewService : ContentListViewServiceBase<IMedia,
IMediaService mediaService,
IMediaTypeService mediaTypeService,
IDataTypeService dataTypeService,
ISqlContext sqlContext,
IMediaSearchService mediaSearchService,
IMediaPermissionAuthorizer mediaPermissionAuthorizer)
: base(mediaTypeService, dataTypeService, sqlContext)
: base(mediaTypeService, dataTypeService, mediaSearchService)
{
_mediaService = mediaService;
_mediaPermissionAuthorizer = mediaPermissionAuthorizer;
@@ -50,27 +48,6 @@ internal sealed class MediaListViewService : ContentListViewServiceBase<IMedia,
return await GetListViewResultAsync(user, media, dataTypeKey, orderBy, null, orderDirection, filter, skip, take);
}
protected override Task<PagedModel<IMedia>> GetPagedChildrenAsync(int id, IQuery<IMedia>? filter, Ordering? ordering, int skip, int take)
{
PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize);
IEnumerable<IMedia> items = _mediaService.GetPagedChildren(
id,
pageNumber,
pageSize,
out var total,
filter,
ordering);
var pagedResult = new PagedModel<IMedia>
{
Items = items,
Total = total,
};
return Task.FromResult(pagedResult);
}
// We can use an authorizer here, as it already handles all the necessary checks for this filtering.
// However, we cannot pass in all the items; we want only the ones that comply, as opposed to
// a general response whether the user has access to all nodes.

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence;
namespace Umbraco.Cms.Infrastructure.Services.Implement;
internal sealed class MediaSearchService : ContentSearchServiceBase<IMedia>, IMediaSearchService
{
private readonly IMediaService _mediaService;
public MediaSearchService(ISqlContext sqlContext, IIdKeyMap idKeyMap, ILogger<MediaSearchService> logger, IMediaService mediaService)
: base(sqlContext, idKeyMap, logger)
=> _mediaService = mediaService;
protected override UmbracoObjectTypes ObjectType => UmbracoObjectTypes.Media;
protected override Task<IEnumerable<IMedia>> SearchChildrenAsync(
IQuery<IMedia>? query,
int parentId,
Ordering? ordering,
long pageNumber,
int pageSize,
out long total)
=> Task.FromResult(_mediaService.GetPagedChildren(
parentId,
pageNumber,
pageSize,
out total,
query,
ordering));
}

View File

@@ -0,0 +1,141 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
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.Infrastructure.Services;
// NOTE: this is not an exhaustive test suite for IContentSearchService, because the core implementation simply
// wraps IContentService to search children (IContentService.GetPagedChildren), and that's tested elsewhere.
// instead, these tests aim at testing the logic of the shared base between the core implementations of
// IContentSearchService and IMediaSearchService (which does the same thing using IMediaService).
[TestFixture]
[UmbracoTest(
Database = UmbracoTestOptions.Database.NewSchemaPerFixture,
PublishedRepositoryEvents = true,
WithApplication = true)]
public class ContentSearchServiceTests : UmbracoIntegrationTest
{
private Dictionary<string, IContent> _contentByName = new ();
protected IContentSearchService ContentSearchService => GetRequiredService<IContentSearchService>();
protected IContentService ContentService => GetRequiredService<IContentService>();
protected IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
[Test]
public async Task Can_Search_Children_Of_System_Root()
{
var result = await ContentSearchService.SearchChildrenAsync(null, null, null, 0, 1000);
Assert.Multiple(() =>
{
Assert.AreEqual(3, result.Total);
Assert.AreEqual(3, result.Items.Count());
});
var resultKeys = result.Items.Select(item => item.Key).ToArray();
var expectedKeys = new[]
{
_contentByName["Root 1"].Key,
_contentByName["Root 2"].Key,
_contentByName["Root 3"].Key
};
CollectionAssert.AreEqual(expectedKeys, resultKeys);
}
[Test]
public async Task Can_Search_Children_Of_Specified_Parent()
{
var result = await ContentSearchService.SearchChildrenAsync(null, _contentByName["Root 1"].Key, null, 0, 1000);
Assert.Multiple(() =>
{
Assert.AreEqual(5, result.Total);
Assert.AreEqual(5, result.Items.Count());
});
var resultKeys = result.Items.Select(item => item.Key).ToArray();
var expectedKeys = new[]
{
_contentByName["Root 1/Child 1"].Key,
_contentByName["Root 1/Child 2"].Key,
_contentByName["Root 1/Child 3"].Key,
_contentByName["Root 1/Child 4"].Key,
_contentByName["Root 1/Child 5"].Key
};
CollectionAssert.AreEqual(expectedKeys, resultKeys);
}
[Test]
public async Task Can_Apply_Pagination_With_Skip_Take()
{
var result = await ContentSearchService.SearchChildrenAsync(null, _contentByName["Root 2"].Key, Ordering.By("name"), 2, 2);
Assert.Multiple(() =>
{
Assert.AreEqual(5, result.Total);
Assert.AreEqual(2, result.Items.Count());
});
var resultKeys = result.Items.Select(item => item.Key).ToArray();
var expectedKeys = new[]
{
_contentByName["Root 2/Child 3"].Key,
_contentByName["Root 2/Child 4"].Key
};
CollectionAssert.AreEqual(expectedKeys, resultKeys);
}
[Test]
public async Task Can_Filter_By_Name()
{
var result = await ContentSearchService.SearchChildrenAsync("2", _contentByName["Root 3"].Key, null);
Assert.Multiple(() =>
{
Assert.AreEqual(1, result.Total);
Assert.AreEqual(1, result.Items.Count());
});
Assert.AreEqual(_contentByName["Root 3/Child 2"].Key, result.Items.First().Key);
}
[SetUp]
public async Task SetUpTest()
{
if (_contentByName.Any())
{
return;
}
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 3))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithName($"Root {rootNumber}")
.Build();
ContentService.Save(root);
_contentByName[root.Name!] = root;
foreach (var childNumber in Enumerable.Range(1, 5))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithName($"Child {childNumber}")
.Build();
ContentService.Save(child);
_contentByName[$"{root.Name!}/{child.Name!}"] = child;
}
}
}
}