V15: Extend search endpoints (#18634)

* Extend content type search endpoint

* Refactor to be able to specify trashed or not

* Simplify and extract into own method

* Fix breaking ctor

* Make non actions in controllers

* Fox up search service

* Add third ctor to avoid errors

* Update query to filter by is element

* Also implement for media

* Minor formatting and clean-up

* Re-introduce (and obsolete) the previous constructor

---------

Co-authored-by: kjac <kja@umbraco.dk>
This commit is contained in:
Nikolaj Geisle
2025-03-19 08:12:44 +00:00
committed by GitHub
parent f3f7fcc051
commit b24c29c647
13 changed files with 198 additions and 26 deletions

View File

@@ -30,14 +30,33 @@ public class SearchDocumentItemController : DocumentItemControllerBase
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> SearchFromParent(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, skip, take, parentId);
=> await SearchWithTrashed(cancellationToken, query, null, skip, take, parentId);
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
[ProducesResponseType(typeof(PagedModel<DocumentItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(
CancellationToken cancellationToken,
string query,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null) =>
await SearchWithTrashed(cancellationToken, query, null, skip, take, parentId, allowedDocumentTypes);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<DocumentItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedDocumentTypes = null)
public async Task<IActionResult> SearchWithTrashed(
CancellationToken cancellationToken,
string query,
bool? trashed = null,
int skip = 0,
int take = 100,
Guid? parentId = null,
[FromQuery] IEnumerable<Guid>? allowedDocumentTypes = null)
{
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, skip, take);
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Document, query, parentId, allowedDocumentTypes, trashed, skip, take);
var result = new PagedModel<DocumentItemResponseModel>
{
Items = searchResult.Items.OfType<IDocumentEntitySlim>().Select(_documentPresentationFactory.CreateItemResponseModel),

View File

@@ -1,45 +1,60 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType.Item;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType.Item;
[ApiVersion("1.0")]
public class SearchDocumentTypeItemController : DocumentTypeItemControllerBase
{
private readonly IEntitySearchService _entitySearchService;
private readonly IContentTypeService _contentTypeService;
private readonly IUmbracoMapper _mapper;
private readonly IContentTypeSearchService _contentTypeSearchService;
[Obsolete("Please use ctor that only accepts IUmbracoMapper & IContentTypeSearchService, scheduled for removal in v17")]
public SearchDocumentTypeItemController(IEntitySearchService entitySearchService, IContentTypeService contentTypeService, IUmbracoMapper mapper)
: this(mapper, StaticServiceProvider.Instance.GetRequiredService<IContentTypeSearchService>())
{
_entitySearchService = entitySearchService;
_contentTypeService = contentTypeService;
_mapper = mapper;
}
[Obsolete("Please use ctor that only accepts IUmbracoMapper & IContentTypeSearchService, scheduled for removal in v17")]
// We need to have this constructor, or else we get ambiguous constructor error
public SearchDocumentTypeItemController(
IEntitySearchService entitySearchService,
IContentTypeService contentTypeService,
IUmbracoMapper mapper,
IContentTypeSearchService contentTypeSearchService)
: this(mapper, contentTypeSearchService)
{
}
[ActivatorUtilitiesConstructor]
public SearchDocumentTypeItemController(IUmbracoMapper mapper, IContentTypeSearchService contentTypeSearchService)
{
_mapper = mapper;
_contentTypeSearchService = contentTypeSearchService;
}
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> Search(CancellationToken cancellationToken, string query, int skip = 0, int take = 100)
=> await SearchDocumentType(cancellationToken, query, null, skip, take);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<DocumentTypeItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Search(CancellationToken cancellationToken, string query, int skip = 0, int take = 100)
public async Task<IActionResult> SearchDocumentType(CancellationToken cancellationToken, string query, bool? isElement = null, int skip = 0, int take = 100)
{
PagedModel<IEntitySlim> searchResult = _entitySearchService.Search(UmbracoObjectTypes.DocumentType, query, skip, take);
if (searchResult.Items.Any() is false)
{
return await Task.FromResult(Ok(new PagedModel<DocumentTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IContentType> contentTypes = _contentTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
PagedModel<IContentType> contentTypes = await _contentTypeSearchService.SearchAsync(query, isElement, cancellationToken, skip, take);
var result = new PagedModel<DocumentTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IContentType, DocumentTypeItemResponseModel>(contentTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IContentType, DocumentTypeItemResponseModel>(contentTypes.Items),
Total = contentTypes.Total
};
return Ok(result);

View File

@@ -32,12 +32,17 @@ public class SearchMediaItemController : MediaItemControllerBase
public async Task<IActionResult> SearchFromParent(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, skip, take, parentId);
[NonAction]
[Obsolete("Scheduled to be removed in v16, use the non obsoleted method instead")]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
=> await SearchFromParentWithAllowedTypes(cancellationToken, query, null, skip, take, parentId);
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MediaItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
public async Task<IActionResult> SearchFromParentWithAllowedTypes(CancellationToken cancellationToken, string query, bool? trashed = null, int skip = 0, int take = 100, Guid? parentId = null, [FromQuery]IEnumerable<Guid>? allowedMediaTypes = null)
{
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, skip, take);
PagedModel<IEntitySlim> searchResult = _indexedEntitySearchService.Search(UmbracoObjectTypes.Media, query, parentId, allowedMediaTypes, trashed, skip, take);
var result = new PagedModel<MediaItemResponseModel>
{
Items = searchResult.Items.OfType<IMediaEntitySlim>().Select(_mediaPresentationFactory.CreateItemResponseModel),

View File

@@ -4,6 +4,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.Changes;
@@ -147,6 +148,16 @@ public class ContentTypeService : ContentTypeServiceBase<IContentTypeRepository,
}
}
public async Task<IEnumerable<IContentType>> GetByQueryAsync(IQuery<IContentType> query, CancellationToken cancellationToken)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
// that one is special because it works across content, media and member types
scope.ReadLock(Constants.Locks.ContentTypes);
IEnumerable<IContentType> contentTypes = Repository.Get(query);
scope.Complete();
return contentTypes;
}
protected override void DeleteItemsOfTypes(IEnumerable<int> typeIds)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
public interface IContentTypeSearchService
{
Task<PagedModel<IContentType>> SearchAsync(string query, bool? isElement, CancellationToken cancellationToken, int skip = 0, int take = 100);
}

View File

@@ -1,4 +1,5 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -30,4 +31,6 @@ public interface IContentTypeService : IContentTypeBaseService<IContentType>
/// <param name="aliases"></param>
/// <returns></returns>
IEnumerable<int> GetAllContentTypeIds(string[] aliases);
Task<IEnumerable<IContentType>> GetByQueryAsync(IQuery<IContentType> query, CancellationToken cancellationToken) => Task.FromResult(Enumerable.Empty<IContentType>());
}

View File

@@ -20,7 +20,19 @@ public interface IIndexedEntitySearchService
PagedModel<IEntitySlim> Search(UmbracoObjectTypes objectType, string query, Guid? parentId, int skip = 0, int take = 100, bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
PagedModel<IEntitySlim> Search(UmbracoObjectTypes objectType, string query, Guid? parentId, IEnumerable<Guid>? contentTypeIds, int skip = 0, int take = 100, bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
string query,
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType,query, skip, take, ignoreUserStartNodes);
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@@ -61,7 +62,19 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
out long totalFound,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, searchFrom, ignoreUserStartNodes);
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, null, searchFrom, ignoreUserStartNodes);
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
public IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
int pageSize,
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, contentTypeAliases, null, searchFrom, ignoreUserStartNodes);
public IEnumerable<ISearchResult> Search(
string query,
@@ -70,6 +83,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
bool? trashed,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
{
@@ -133,6 +147,12 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
? currentUser.CalculateMediaStartNodeIds(_entityService, _appCaches)
: Array.Empty<int>();
AppendPath(sb, UmbracoObjectTypes.Media, allMediaStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
if (trashed.HasValue)
{
AppendRequiredTrashPath(trashed.Value, sb, Constants.System.RecycleBinMedia);
}
break;
case UmbracoEntityTypes.Document:
type = "content";
@@ -146,6 +166,12 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
? currentUser.CalculateContentStartNodeIds(_entityService, _appCaches)
: Array.Empty<int>();
AppendPath(sb, UmbracoObjectTypes.Document, allContentStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
if (trashed.HasValue)
{
AppendRequiredTrashPath(trashed.Value, sb, Constants.System.RecycleBinContent);
}
break;
default:
throw new NotSupportedException("The " + typeof(BackOfficeExamineSearcher) +
@@ -181,6 +207,14 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
return result;
}
private void AppendRequiredTrashPath(bool trashed, StringBuilder sb, int recycleBinId)
{
var requiredOrNotString = trashed ? "+" : "!";
var trashPath = $"-1,{recycleBinId}";
trashPath = trashPath.Replace("-", "\\-").Replace(",", "\\,");
sb.Append($"{requiredOrNotString}__Path:{trashPath}\\,* ");
}
private bool BuildQuery(StringBuilder sb, string query, string? searchFrom, List<string> fields, string type)
{
//build a lucene query:
@@ -394,6 +428,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
searchFromId > 0
? entityService.GetAllPaths(objectType, searchFromId).FirstOrDefault()
: null;
if (entityPath != null)
{
// find... only what's underneath

View File

@@ -78,6 +78,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IContentListViewService, ContentListViewService>();
builder.Services.AddUnique<IMediaListViewService, MediaListViewService>();
builder.Services.AddUnique<IEntitySearchService, EntitySearchService>();
builder.Services.AddUnique<IContentTypeSearchService, ContentTypeSearchService>();
builder.Services.AddUnique<IIndexedEntitySearchService, IndexedEntitySearchService>();
builder.Services.TryAddTransient<IReservedFieldNamesService, ReservedFieldNamesService>();

View File

@@ -18,7 +18,7 @@ public interface IBackOfficeExamineSearcher
string? searchFrom = null,
bool ignoreUserStartNodes = false);
// default implementation to avoid breaking changes falls back to old behaviour
[Obsolete("Please use the method that accepts all parameters. Will be removed in V17.")]
IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
@@ -28,5 +28,18 @@ public interface IBackOfficeExamineSearcher
string[]? contentTypeAliases,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, searchFrom, ignoreUserStartNodes);
// default implementation to avoid breaking changes falls back to old behaviour
IEnumerable<ISearchResult> Search(
string query,
UmbracoEntityTypes entityType,
int pageSize,
long pageIndex,
out long totalFound,
string[]? contentTypeAliases,
bool? trashed,
string? searchFrom = null,
bool ignoreUserStartNodes = false)
=> Search(query, entityType, pageSize, pageIndex, out totalFound, null, searchFrom, ignoreUserStartNodes);
}

View File

@@ -18,6 +18,7 @@ public class UmbracoTreeSearcherFields : IUmbracoTreeSearcherFields
UmbracoExamineFieldNames.CategoryFieldName,
"parentID",
UmbracoExamineFieldNames.ItemTypeFieldName,
UmbracoExamineFieldNames.IndexPathFieldName,
};
private readonly ISet<string> _backOfficeMediaFieldsToLoad =

View File

@@ -0,0 +1,37 @@
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;
namespace Umbraco.Cms.Infrastructure.Services;
internal sealed class ContentTypeSearchService : IContentTypeSearchService
{
private readonly ISqlContext _sqlContext;
private readonly IContentTypeService _contentTypeService;
public ContentTypeSearchService(ISqlContext sqlContext, IContentTypeService contentTypeService)
{
_sqlContext = sqlContext;
_contentTypeService = contentTypeService;
}
public async Task<PagedModel<IContentType>> SearchAsync(string query, bool? isElement, CancellationToken cancellationToken, int skip = 0, int take = 100)
{
// if the query is a GUID, search for that explicitly
Guid.TryParse(query, out Guid guidQuery);
IQuery<IContentType> nameQuery = isElement is not null ?
_sqlContext.Query<IContentType>().Where(x => (x.Name!.Contains(query) || x.Key == guidQuery) && x.IsElement == isElement) :
_sqlContext.Query<IContentType>().Where(x => x.Name!.Contains(query) || x.Key == guidQuery);
IContentType[] contentTypes = (await _contentTypeService.GetByQueryAsync(nameQuery, cancellationToken)).ToArray();
return new PagedModel<IContentType>
{
Items = contentTypes.Skip(skip).Take(take),
Total = contentTypes.Count()
};
}
}

View File

@@ -52,7 +52,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType, query, parentId, null, skip, take, ignoreUserStartNodes);
=> Search(objectType, query, parentId, null, null, skip, take, ignoreUserStartNodes);
public PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
@@ -62,6 +62,17 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
=> Search(objectType, query, parentId, contentTypeIds, null, skip, take, ignoreUserStartNodes);
public PagedModel<IEntitySlim> Search(
UmbracoObjectTypes objectType,
string query,
Guid? parentId,
IEnumerable<Guid>? contentTypeIds,
bool? trashed,
int skip = 0,
int take = 100,
bool ignoreUserStartNodes = false)
{
UmbracoEntityTypes entityType = objectType switch
{
@@ -91,6 +102,7 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
pageNumber,
out var totalFound,
contentTypeAliases,
trashed,
ignoreUserStartNodes: ignoreUserStartNodes,
searchFrom: parentId?.ToString());