From b3bedb4efee8866a7c5cfee9be2414adcf8f0a8a Mon Sep 17 00:00:00 2001 From: karlmacklin <353806+karlmacklin@users.noreply.github.com> Date: Mon, 15 May 2023 08:42:58 +0200 Subject: [PATCH 01/11] XPath can unambiguously use $site/$parent (#14127) * XPath can unambiguously use $site/$parent * add deprecation notices and obsolete methods * Update deprecation description text/instruction Co-authored-by: Mole * Small spelling fix on deprecation description * keep depr. getByQuery and handle legacy usage --------- Co-authored-by: Kalle Macklin Co-authored-by: Mole --- .../Xml/UmbracoXPathPathSyntaxParser.cs | 53 ++++++++++++++----- .../Routing/NotFoundHandlerHelper.cs | 1 + .../Controllers/EntityController.cs | 21 +++++--- .../src/common/resources/entity.resource.js | 24 +++++++-- .../contentpicker/contentpicker.controller.js | 9 ++-- 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs b/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs index bb5c186ca6..2a01d42dc7 100644 --- a/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs +++ b/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs @@ -8,13 +8,23 @@ namespace Umbraco.Cms.Core.Xml; /// public class UmbracoXPathPathSyntaxParser { + [Obsolete("This will be removed in Umbraco 13. Use ParseXPathQuery which accepts a parentId instead")] + public static string ParseXPathQuery( + string xpathExpression, + int? nodeContextId, + Func?> getPath, + Func publishedContentExists) => ParseXPathQuery(xpathExpression, nodeContextId, null, getPath, publishedContentExists); + /// /// Parses custom umbraco xpath expression /// /// The Xpath expression /// - /// The current node id context of executing the query - null if there is no current node, in which case - /// some of the parameters like $current, $parent, $site will be disabled + /// The current node id context of executing the query - null if there is no current node. + /// + /// + /// The parent node id of the current node id context of executing the query. With this we can determine the + /// $parent and $site parameters even if the current node is not yet published. /// /// The callback to create the nodeId path, given a node Id /// The callback to return whether a published node exists based on Id @@ -22,6 +32,7 @@ public class UmbracoXPathPathSyntaxParser public static string ParseXPathQuery( string xpathExpression, int? nodeContextId, + int? parentId, Func?> getPath, Func publishedContentExists) { @@ -84,19 +95,27 @@ public class UmbracoXPathPathSyntaxParser // parseable items: var vars = new Dictionary>(); - // These parameters must have a node id context - if (nodeContextId.HasValue) + if (parentId.HasValue) + { + vars.Add("$parent", q => + { + var path = getPath(parentId.Value)?.ToArray(); + var closestPublishedAncestorId = getClosestPublishedAncestor(path); + return q.Replace("$parent", string.Format(rootXpath, closestPublishedAncestorId)); + }); + + vars.Add("$site", q => + { + var closestPublishedAncestorId = getClosestPublishedAncestor(getPath(parentId.Value)); + return q.Replace( + "$site", + string.Format(rootXpath, closestPublishedAncestorId) + "/ancestor-or-self::*[@level = 1]"); + }); + } + else if (nodeContextId.HasValue) { - vars.Add("$current", q => - { - var closestPublishedAncestorId = getClosestPublishedAncestor(getPath(nodeContextId.Value)); - return q.Replace("$current", string.Format(rootXpath, closestPublishedAncestorId)); - }); - vars.Add("$parent", q => { - // remove the first item in the array if its the current node - // this happens when current is published, but we are looking for its parent specifically var path = getPath(nodeContextId.Value)?.ToArray(); if (path?[0] == nodeContextId.ToString()) { @@ -116,6 +135,16 @@ public class UmbracoXPathPathSyntaxParser }); } + // These parameters must have a node id context + if (nodeContextId.HasValue) + { + vars.Add("$current", q => + { + var closestPublishedAncestorId = getClosestPublishedAncestor(getPath(nodeContextId.Value)); + return q.Replace("$current", string.Format(rootXpath, closestPublishedAncestorId)); + }); + } + // TODO: This used to just replace $root with string.Empty BUT, that would never work // the root is always "/root . Need to confirm with Per why this was string.Empty before! vars.Add("$root", q => q.Replace("$root", "/root")); diff --git a/src/Umbraco.Infrastructure/Routing/NotFoundHandlerHelper.cs b/src/Umbraco.Infrastructure/Routing/NotFoundHandlerHelper.cs index f945f03b28..04ca6b4bb2 100644 --- a/src/Umbraco.Infrastructure/Routing/NotFoundHandlerHelper.cs +++ b/src/Umbraco.Infrastructure/Routing/NotFoundHandlerHelper.cs @@ -79,6 +79,7 @@ internal class NotFoundHandlerHelper var xpathResult = UmbracoXPathPathSyntaxParser.ParseXPathQuery( errorPage.ContentXPath!, domainContentId, + null, nodeid => { IEntitySlim? ent = entityService.Get(nodeid); diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index 36e7d46df1..9c4f6aa258 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -555,6 +555,15 @@ public class EntityController : UmbracoAuthorizedJsonController return Ok(returnUrl); } + /// + /// Gets an entity by a xpath query - OBSOLETE + /// + /// + /// + /// + /// + [Obsolete("This will be removed in Umbraco 13. Use GetByXPath instead")] + public ActionResult? GetByQuery(string query, int nodeContextId, UmbracoEntityTypes type) => GetByXPath(query, nodeContextId, null, type); /// /// Gets an entity by a xpath query @@ -562,19 +571,16 @@ public class EntityController : UmbracoAuthorizedJsonController /// /// /// + /// /// - public ActionResult? GetByQuery(string query, int nodeContextId, UmbracoEntityTypes type) + public ActionResult? GetByXPath(string query, int nodeContextId, int? parentId, UmbracoEntityTypes type) { - // TODO: Rename this!!! It's misleading, it should be GetByXPath - - if (type != UmbracoEntityTypes.Document) { throw new ArgumentException("Get by query is only compatible with entities of type Document"); } - - var q = ParseXPathQuery(query, nodeContextId); + var q = ParseXPathQuery(query, nodeContextId, parentId); IPublishedContent? node = _publishedContentQuery.ContentSingleAtXPath(q); if (node == null) @@ -586,10 +592,11 @@ public class EntityController : UmbracoAuthorizedJsonController } // PP: Work in progress on the query parser - private string ParseXPathQuery(string query, int id) => + private string ParseXPathQuery(string query, int id, int? parentId) => UmbracoXPathPathSyntaxParser.ParseXPathQuery( query, id, + parentId, nodeid => { IEntitySlim? ent = _entityService.Get(nodeid); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js index 3b2618a82b..1c6f8d6184 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/entity.resource.js @@ -318,9 +318,22 @@ function entityResource($q, $http, umbRequestHelper) { 'Failed to retrieve entity data for ids ' + ids); }, + /** + * @deprecated use getByXPath instead. + */ + getByQuery: function (query, nodeContextId, type) { + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "entityApiBaseUrl", + "GetByQuery", + [{ query: query }, { nodeContextId: nodeContextId }, { type: type }])), + 'Failed to retrieve entity data for query ' + query); + }, + /** * @ngdoc method - * @name umbraco.resources.entityResource#getByQuery + * @name umbraco.resources.entityResource#getByXPath * @methodOf umbraco.resources.entityResource * * @description @@ -329,7 +342,7 @@ function entityResource($q, $http, umbRequestHelper) { * ##usage *
          * //get content by xpath
-         * entityResource.getByQuery("$current", -1, "Document")
+         * entityResource.getByXPath("$current", -1, -1, "Document")
          *    .then(function(ent) {
          *        var myDoc = ent;
          *        alert('its here!');
@@ -338,17 +351,18 @@ function entityResource($q, $http, umbRequestHelper) {
          *
          * @param {string} query xpath to use in query
          * @param {Int} nodeContextId id id to start from
+         * @param {Int} parentId id id of the parent to the starting point
          * @param {string} type Object type name
          * @returns {Promise} resourcePromise object containing the entity.
          *
          */
-        getByQuery: function (query, nodeContextId, type) {
+        getByXPath: function (query, nodeContextId, parentId, type) {
             return umbRequestHelper.resourcePromise(
                $http.get(
                    umbRequestHelper.getApiUrl(
                        "entityApiBaseUrl",
-                       "GetByQuery",
-                       [{ query: query }, { nodeContextId: nodeContextId }, { type: type }])),
+                       "GetByXPath",
+                       [{ query: query }, { nodeContextId: nodeContextId }, { parentId: parentId }, { type: type }])),
                'Failed to retrieve entity data for query ' + query);
         },
 
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js
index 09b9d2c98b..52f147bce0 100644
--- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/contentpicker/contentpicker.controller.js
@@ -245,9 +245,12 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso
         dialogOptions.startNodeId = -1;
     }
     else if ($scope.model.config.startNode.query) {
-        //if we have a query for the startnode, we will use that.
-        var rootId = editorState.current.id;
-        entityResource.getByQuery($scope.model.config.startNode.query, rootId, "Document").then(function (ent) {
+        entityResource.getByXPath(
+            $scope.model.config.startNode.query,
+            editorState.current.id,
+            editorState.current.parentId,
+            "Document"
+        ).then(function (ent) {
             dialogOptions.startNodeId = ($scope.model.config.idType === "udi" ? ent.udi : ent.id).toString();
         });
     }

From 867a8a4066915d0839269865bba69a5b4e274a85 Mon Sep 17 00:00:00 2001
From: Nikolaj 
Date: Wed, 17 May 2023 13:25:08 +0200
Subject: [PATCH 02/11] Bump version to next RC

---
 version.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/version.json b/version.json
index e22da7303c..b465096390 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
 {
   "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
-  "version": "12.0.0-rc1",
+  "version": "12.0.0-rc2",
   "assemblyVersion": {
     "precision": "build"
   },

From 16f448a802f352a9859bf3c45dcca626ed097be9 Mon Sep 17 00:00:00 2001
From: Dhanesh Kumar Mj <58820887+dKumarmj@users.noreply.github.com>
Date: Fri, 19 May 2023 14:51:40 +0530
Subject: [PATCH 03/11] [Fix] Block editor labels showing Angular JS on first
 load. (#14143)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Dhanesh Kumar <“dhanesh.kumar@phases.io”>
(cherry picked from commit 58695b6e9fdafc0539a757e6d842e44f618d833d)
---
 .../src/common/services/blockeditormodelobject.service.js    | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
index abb81c38dc..20661d5d1c 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
@@ -639,10 +639,9 @@
                         mapToPropertyModel(this.settings, this.settingsData);
                     }
                 };
-
                 // first time instant update of label.
-                blockObject.label = (blockObject.config.label || blockObject.content?.contentTypeName) ?? "" ;
-                blockObject.index = 0;
+              blockObject.label = blockObject.content?.contentTypeName || "";
+                blockObject.index = 0; 
 
                 if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) {
                     var labelElement = $('
', { text: blockObject.config.label}); From 7d3c22b28a43392ea427de578e97b4935d2b45d9 Mon Sep 17 00:00:00 2001 From: Dhanesh Kumar Mj <58820887+dKumarmj@users.noreply.github.com> Date: Fri, 19 May 2023 14:51:40 +0530 Subject: [PATCH 04/11] [Fix] Block editor labels showing Angular JS on first load. (#14143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dhanesh Kumar <“dhanesh.kumar@phases.io”> (cherry picked from commit 58695b6e9fdafc0539a757e6d842e44f618d833d) --- .../src/common/services/blockeditormodelobject.service.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index abb81c38dc..20661d5d1c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -639,10 +639,9 @@ mapToPropertyModel(this.settings, this.settingsData); } }; - // first time instant update of label. - blockObject.label = (blockObject.config.label || blockObject.content?.contentTypeName) ?? "" ; - blockObject.index = 0; + blockObject.label = blockObject.content?.contentTypeName || ""; + blockObject.index = 0; if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) { var labelElement = $('
', { text: blockObject.config.label}); From 8bbca79e55e5de0b62c148be45bc827b92eb6a82 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 22 May 2023 11:06:22 +0200 Subject: [PATCH 05/11] Split the Examine specifics from API query service (#14257) * Split the Examine specifics from API query service to a provider based model * Review changes: Use paged model as provider return value + add logging --- .../Controllers/ContentApiControllerBase.cs | 4 - .../UmbracoBuilderExtensions.cs | 1 + .../Services/ApiContentQueryProvider.cs | 164 +++++++++++++ .../Services/ApiContentQueryService.cs | 230 ++++-------------- .../DeliveryApi/IApiContentQueryProvider.cs | 26 ++ .../ApiContentQueryOperationStatus.cs | 1 - 6 files changed, 235 insertions(+), 191 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs index 834332fcbb..e260200d5e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs @@ -31,10 +31,6 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase .WithTitle("Filter option not found") .WithDetail("One of the attempted 'filter' options does not exist") .Build()), - ApiContentQueryOperationStatus.IndexNotFound => BadRequest(new ProblemDetailsBuilder() - .WithTitle("Examine index not found") - .WithDetail($"No index found with name {Constants.UmbracoIndexes.DeliveryApiContentIndexName}") - .Build()), ApiContentQueryOperationStatus.SelectorOptionNotFound => BadRequest(new ProblemDetailsBuilder() .WithTitle("Selector option not found") .WithDetail("The attempted 'fetch' option does not exist") diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 6370fc24f9..37d84bc273 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -28,6 +28,7 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs new file mode 100644 index 0000000000..d9ad8d0f15 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs @@ -0,0 +1,164 @@ +using Examine; +using Examine.Search; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Extensions; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Delivery.Services; + +/// +/// This is the Examine implementation of content querying for the Delivery API. +/// +internal sealed class ApiContentQueryProvider : IApiContentQueryProvider +{ + private const string ItemIdFieldName = "itemId"; + private readonly IExamineManager _examineManager; + private readonly ILogger _logger; + private readonly string _fallbackGuidValue; + private readonly Dictionary _fieldTypes; + + public ApiContentQueryProvider( + IExamineManager examineManager, + ContentIndexHandlerCollection indexHandlers, + ILogger logger) + { + _examineManager = examineManager; + _logger = logger; + + // A fallback value is needed for Examine queries in case we don't have a value - we can't pass null or empty string + // It is set to a random guid since this would be highly unlikely to yield any results + _fallbackGuidValue = Guid.NewGuid().ToString("D"); + + // build a look-up dictionary of field types by field name + _fieldTypes = indexHandlers + .SelectMany(handler => handler.GetFields()) + .DistinctBy(field => field.FieldName) + .ToDictionary(field => field.FieldName, field => field.FieldType, StringComparer.InvariantCultureIgnoreCase); + } + + public PagedModel ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList sortOptions, string culture, int skip, int take) + { + if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? index)) + { + _logger.LogError("Could not find the index {IndexName} when attempting to execute a query.", Constants.UmbracoIndexes.DeliveryApiContentIndexName); + return new PagedModel(); + } + + IBooleanOperation queryOperation = BuildSelectorOperation(selectorOption, index, culture); + + ApplyFiltering(filterOptions, queryOperation); + ApplySorting(sortOptions, queryOperation); + + ISearchResults? results = queryOperation + .SelectField(ItemIdFieldName) + .Execute(QueryOptions.SkipTake(skip, take)); + + if (results is null) + { + // The query yield no results + return new PagedModel(); + } + + Guid[] items = results + .Where(r => r.Values.ContainsKey(ItemIdFieldName)) + .Select(r => Guid.Parse(r.Values[ItemIdFieldName])) + .ToArray(); + + return new PagedModel(results.TotalItemCount, items); + } + + public SelectorOption AllContentSelectorOption() => new() + { + FieldName = UmbracoExamineFieldNames.CategoryFieldName, Values = new[] { "content" } + }; + + private IBooleanOperation BuildSelectorOperation(SelectorOption selectorOption, IIndex index, string culture) + { + IQuery query = index.Searcher.CreateQuery(); + + IBooleanOperation selectorOperation = selectorOption.Values.Length == 1 + ? query.Field(selectorOption.FieldName, selectorOption.Values.First()) + : query.GroupedOr(new[] { selectorOption.FieldName }, selectorOption.Values); + + // Item culture must be either the requested culture or "none" + selectorOperation.And().GroupedOr(new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), "none"); + + return selectorOperation; + } + + private void ApplyFiltering(IList filterOptions, IBooleanOperation queryOperation) + { + void HandleExact(IQuery query, string fieldName, string[] values) + { + if (values.Length == 1) + { + query.Field(fieldName, values[0]); + } + else + { + query.GroupedOr(new[] { fieldName }, values); + } + } + + foreach (FilterOption filterOption in filterOptions) + { + var values = filterOption.Values.Any() + ? filterOption.Values + : new[] { _fallbackGuidValue }; + + switch (filterOption.Operator) + { + case FilterOperation.Is: + // TODO: test this for explicit word matching + HandleExact(queryOperation.And(), filterOption.FieldName, values); + break; + case FilterOperation.IsNot: + // TODO: test this for explicit word matching + HandleExact(queryOperation.Not(), filterOption.FieldName, values); + break; + // TODO: Fix + case FilterOperation.Contains: + break; + // TODO: Fix + case FilterOperation.DoesNotContain: + break; + default: + continue; + } + } + } + + private void ApplySorting(IList sortOptions, IOrdering ordering) + { + foreach (SortOption sort in sortOptions) + { + if (_fieldTypes.TryGetValue(sort.FieldName, out FieldType fieldType) is false) + { + _logger.LogWarning( + "Sort implementation for field name {FieldName} does not match an index handler implementation, cannot resolve field type.", + sort.FieldName); + continue; + } + + SortType sortType = fieldType switch + { + FieldType.Number => SortType.Int, + FieldType.Date => SortType.Long, + FieldType.StringRaw => SortType.String, + FieldType.StringAnalyzed => SortType.String, + FieldType.StringSortable => SortType.String, + _ => throw new ArgumentOutOfRangeException(nameof(fieldType)) + }; + + ordering = sort.Direction switch + { + Direction.Ascending => ordering.OrderBy(new SortableField(sort.FieldName, sortType)), + Direction.Descending => ordering.OrderByDescending(new SortableField(sort.FieldName, sortType)), + _ => ordering + }; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs index d044d774d1..8aac5db6ee 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs @@ -1,57 +1,35 @@ -using Examine; -using Examine.Search; -using Microsoft.Extensions.Logging; using Umbraco.Cms.Api.Delivery.Indexing.Selectors; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Infrastructure.Examine; -using Umbraco.Extensions; using Umbraco.New.Cms.Core.Models; namespace Umbraco.Cms.Api.Delivery.Services; internal sealed class ApiContentQueryService : IApiContentQueryService { - private const string ItemIdFieldName = "itemId"; - private readonly IExamineManager _examineManager; private readonly IRequestStartItemProviderAccessor _requestStartItemProviderAccessor; private readonly SelectorHandlerCollection _selectorHandlers; private readonly FilterHandlerCollection _filterHandlers; private readonly SortHandlerCollection _sortHandlers; private readonly IVariationContextAccessor _variationContextAccessor; - private readonly ILogger _logger; - private readonly string _fallbackGuidValue; - private readonly Dictionary _fieldTypes; + private readonly IApiContentQueryProvider _apiContentQueryProvider; public ApiContentQueryService( - IExamineManager examineManager, IRequestStartItemProviderAccessor requestStartItemProviderAccessor, SelectorHandlerCollection selectorHandlers, FilterHandlerCollection filterHandlers, SortHandlerCollection sortHandlers, - ContentIndexHandlerCollection indexHandlers, - ILogger logger, - IVariationContextAccessor variationContextAccessor) + IVariationContextAccessor variationContextAccessor, + IApiContentQueryProvider apiContentQueryProvider) { - _examineManager = examineManager; _requestStartItemProviderAccessor = requestStartItemProviderAccessor; _selectorHandlers = selectorHandlers; _filterHandlers = filterHandlers; _sortHandlers = sortHandlers; _variationContextAccessor = variationContextAccessor; - _logger = logger; - - // A fallback value is needed for Examine queries in case we don't have a value - we can't pass null or empty string - // It is set to a random guid since this would be highly unlikely to yield any results - _fallbackGuidValue = Guid.NewGuid().ToString("D"); - - // build a look-up dictionary of field types by field name - _fieldTypes = indexHandlers - .SelectMany(handler => handler.GetFields()) - .DistinctBy(field => field.FieldName) - .ToDictionary(field => field.FieldName, field => field.FieldType, StringComparer.InvariantCultureIgnoreCase); + _apiContentQueryProvider = apiContentQueryProvider; } /// @@ -59,198 +37,78 @@ internal sealed class ApiContentQueryService : IApiContentQueryService { var emptyResult = new PagedModel(); - if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? apiIndex)) - { - return Attempt.FailWithStatus(ApiContentQueryOperationStatus.IndexNotFound, emptyResult); - } - - IQuery baseQuery = apiIndex.Searcher.CreateQuery(); - - // Handle Selecting - IBooleanOperation? queryOperation = HandleSelector(fetch, baseQuery); - - // If no Selector could be found, we return no results - if (queryOperation is null) + SelectorOption? selectorOption = GetSelectorOption(fetch); + if (selectorOption is null) { + // If no Selector could be found, we return no results return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SelectorOptionNotFound, emptyResult); } - // Item culture must be either the requested culture or "none" - var culture = CurrentCulture(); - queryOperation.And().GroupedOr(new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), "none"); - - // Handle Filtering - var canApplyFiltering = CanHandleFiltering(filters, queryOperation); - - // If there is an invalid Filter option, we return no results - if (canApplyFiltering is false) + var filterOptions = new List(); + foreach (var filter in filters) { - return Attempt.FailWithStatus(ApiContentQueryOperationStatus.FilterOptionNotFound, emptyResult); + FilterOption? filterOption = GetFilterOption(filter); + if (filterOption is null) + { + // If there is an invalid Filter option, we return no results + return Attempt.FailWithStatus(ApiContentQueryOperationStatus.FilterOptionNotFound, emptyResult); + } + + filterOptions.Add(filterOption); } - // Handle Sorting - IOrdering? sortQuery = HandleSorting(sorts, queryOperation); - - // If there is an invalid Sort option, we return no results - if (sortQuery is null) + var sortOptions = new List(); + foreach (var sort in sorts) { - return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SortOptionNotFound, emptyResult); + SortOption? sortOption = GetSortOption(sort); + if (sortOption is null) + { + // If there is an invalid Sort option, we return no results + return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SortOptionNotFound, emptyResult); + } + + sortOptions.Add(sortOption); } - ISearchResults? results = sortQuery - .SelectField(ItemIdFieldName) - .Execute(QueryOptions.SkipTake(skip, take)); + var culture = _variationContextAccessor.VariationContext?.Culture ?? string.Empty; - if (results is null) - { - // The query yield no results - return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, emptyResult); - } - - Guid[] items = results - .Where(r => r.Values.ContainsKey(ItemIdFieldName)) - .Select(r => Guid.Parse(r.Values[ItemIdFieldName])) - .ToArray(); - - return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, new PagedModel(results.TotalItemCount, items)); + PagedModel result = _apiContentQueryProvider.ExecuteQuery(selectorOption, filterOptions, sortOptions, culture, skip, take); + return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, result); } - private IBooleanOperation? HandleSelector(string? fetch, IQuery baseQuery) + private SelectorOption? GetSelectorOption(string? fetch) { - string? fieldName = null; - string[] fieldValues = Array.Empty(); - if (fetch is not null) { ISelectorHandler? selectorHandler = _selectorHandlers.FirstOrDefault(h => h.CanHandle(fetch)); - SelectorOption? selector = selectorHandler?.BuildSelectorOption(fetch); - - if (selector is null) - { - return null; - } - - fieldName = selector.FieldName; - fieldValues = selector.Values.Any() - ? selector.Values - : new[] { _fallbackGuidValue }; + return selectorHandler?.BuildSelectorOption(fetch); } - // Take into account the "start-item" header if present, as it defines a starting root node to query from - if (fieldName is null && _requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider)) + if (_requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider)) { IPublishedContent? startItem = requestStartItemProvider.GetStartItem(); if (startItem is not null) { // Reusing the boolean operation of the "Descendants" selector, as we want to get all the nodes from the given starting point - fieldName = DescendantsSelectorIndexer.FieldName; - fieldValues = new [] { startItem.Key.ToString() }; + return new SelectorOption + { + FieldName = DescendantsSelectorIndexer.FieldName, Values = new[] { startItem.Key.ToString() } + }; } } - // If no params or no fetch value, get everything from the index - this is a way to do that with Examine - fieldName ??= UmbracoExamineFieldNames.CategoryFieldName; - fieldValues = fieldValues.Any() ? fieldValues : new [] { "content" }; - - return fieldValues.Length == 1 - ? baseQuery.Field(fieldName, fieldValues.First()) - : baseQuery.GroupedOr(new[] { fieldName }, fieldValues); + return _apiContentQueryProvider.AllContentSelectorOption(); } - private bool CanHandleFiltering(IEnumerable filters, IBooleanOperation queryOperation) + private FilterOption? GetFilterOption(string filter) { - void HandleExact(IQuery query, string fieldName, string[] values) - { - if (values.Length == 1) - { - query.Field(fieldName, values[0]); - } - else - { - query.GroupedOr(new[] { fieldName }, values); - } - } - - foreach (var filterValue in filters) - { - IFilterHandler? filterHandler = _filterHandlers.FirstOrDefault(h => h.CanHandle(filterValue)); - FilterOption? filter = filterHandler?.BuildFilterOption(filterValue); - - if (filter is null) - { - return false; - } - - var values = filter.Values.Any() - ? filter.Values - : new[] { _fallbackGuidValue }; - - switch (filter.Operator) - { - case FilterOperation.Is: - // TODO: test this for explicit word matching - HandleExact(queryOperation.And(), filter.FieldName, values); - break; - case FilterOperation.IsNot: - // TODO: test this for explicit word matching - HandleExact(queryOperation.Not(), filter.FieldName, values); - break; - // TODO: Fix - case FilterOperation.Contains: - break; - // TODO: Fix - case FilterOperation.DoesNotContain: - break; - default: - continue; - } - } - - return true; + IFilterHandler? filterHandler = _filterHandlers.FirstOrDefault(h => h.CanHandle(filter)); + return filterHandler?.BuildFilterOption(filter); } - private IOrdering? HandleSorting(IEnumerable sorts, IBooleanOperation queryCriteria) + private SortOption? GetSortOption(string sort) { - IOrdering? orderingQuery = null; - - foreach (var sortValue in sorts) - { - ISortHandler? sortHandler = _sortHandlers.FirstOrDefault(h => h.CanHandle(sortValue)); - SortOption? sort = sortHandler?.BuildSortOption(sortValue); - - if (sort is null) - { - return null; - } - - if (_fieldTypes.TryGetValue(sort.FieldName, out FieldType fieldType) is false) - { - _logger.LogWarning("Sort implementation for field name {FieldName} does not match an index handler implementation, cannot resolve field type.", sort.FieldName); - continue; - } - - SortType sortType = fieldType switch - { - FieldType.Number => SortType.Int, - FieldType.Date => SortType.Long, - FieldType.StringRaw => SortType.String, - FieldType.StringAnalyzed => SortType.String, - FieldType.StringSortable => SortType.String, - _ => throw new ArgumentOutOfRangeException(nameof(fieldType)) - }; - - orderingQuery = sort.Direction switch - { - Direction.Ascending => queryCriteria.OrderBy(new SortableField(sort.FieldName, sortType)), - Direction.Descending => queryCriteria.OrderByDescending(new SortableField(sort.FieldName, sortType)), - _ => orderingQuery - }; - } - - // Keep the index sorting as default - return orderingQuery ?? queryCriteria.OrderBy(); + ISortHandler? sortHandler = _sortHandlers.FirstOrDefault(h => h.CanHandle(sort)); + return sortHandler?.BuildSortOption(sort); } - - private string CurrentCulture() - => _variationContextAccessor.VariationContext?.Culture ?? string.Empty; } diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs new file mode 100644 index 0000000000..df889184fd --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs @@ -0,0 +1,26 @@ +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// Concrete implementation of content querying (e.g. based on Examine) +/// +public interface IApiContentQueryProvider +{ + /// + /// Returns a page of item ids that passed the search criteria. + /// + /// The selector option of the search criteria. + /// The filter options of the search criteria. + /// The sorting options of the search criteria. + /// The requested culture. + /// Number of search results to skip (for pagination). + /// Number of search results to retrieve (for pagination). + /// A paged model containing the resulting IDs and the total number of results that matching the search criteria. + PagedModel ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList sortOptions, string culture, int skip, int take); + + /// + /// Returns a selector option that can be applied to fetch "all content" (i.e. if a selector option is not present when performing a search). + /// + SelectorOption AllContentSelectorOption(); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs index 0c3c859070..2e8da1a0b1 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ApiContentQueryOperationStatus.cs @@ -4,7 +4,6 @@ public enum ApiContentQueryOperationStatus { Success, FilterOptionNotFound, - IndexNotFound, SelectorOptionNotFound, SortOptionNotFound } From 1e6c2206105287d75c84ec3bd76e34bf637253a9 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 22 May 2023 12:29:16 +0200 Subject: [PATCH 06/11] Downgrade OpenIdDict (#14279) --- src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj | 4 ++-- .../Umbraco.Cms.Persistence.EFCore.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj index 5a2dfa69ef..9deb226783 100644 --- a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj +++ b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj index 3e6e57983f..3702db461b 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj @@ -9,7 +9,7 @@ - + From 459d664531b742b40674ecbcc7477af69ed10815 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 22 May 2023 12:44:52 +0200 Subject: [PATCH 07/11] V12: Map |DataDirectory| to path in connectionString (#14278) * Add Data directory as constants * Use new constants instead of internal ones * Replace data directory in connection string if its there * Update src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs --------- Co-authored-by: Zeegaan Co-authored-by: Mole --- .../UmbracoEFCoreServiceCollectionExtensions.cs | 9 ++++++++- .../Configuration/Models/ConnectionStrings.cs | 2 +- src/Umbraco.Core/Constants-System.cs | 10 ++++++++++ .../Extensions/ConfigurationExtensions.cs | 14 ++------------ 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index 857661fd83..4d47e64448 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.DistributedLocking; using Umbraco.Cms.Persistence.EFCore.Locking; using Umbraco.Cms.Persistence.EFCore.Scoping; @@ -16,6 +16,13 @@ public static class UmbracoEFCoreServiceCollectionExtensions { defaultEFCoreOptionsAction ??= DefaultOptionsAction; + // Replace data directory + string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); + if (string.IsNullOrEmpty(dataDirectory) is false) + { + connectionString = connectionString.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); + } + services.AddDbContext( options => { diff --git a/src/Umbraco.Core/Configuration/Models/ConnectionStrings.cs b/src/Umbraco.Core/Configuration/Models/ConnectionStrings.cs index a5161eca86..1d7690ab52 100644 --- a/src/Umbraco.Core/Configuration/Models/ConnectionStrings.cs +++ b/src/Umbraco.Core/Configuration/Models/ConnectionStrings.cs @@ -15,7 +15,7 @@ public class ConnectionStrings // TODO: Rename to [Umbraco]ConnectionString (sin /// /// The DataDirectory placeholder. /// - public const string DataDirectoryPlaceholder = ConfigurationExtensions.DataDirectoryPlaceholder; + public const string DataDirectoryPlaceholder = Constants.System.DataDirectoryPlaceholder; /// /// The postfix used to identify a connection strings provider setting. diff --git a/src/Umbraco.Core/Constants-System.cs b/src/Umbraco.Core/Constants-System.cs index 43de01995b..4a88da6459 100644 --- a/src/Umbraco.Core/Constants-System.cs +++ b/src/Umbraco.Core/Constants-System.cs @@ -64,5 +64,15 @@ public static partial class Constants public const string UmbracoConnectionName = "umbracoDbDSN"; public const string DefaultUmbracoPath = "~/umbraco"; + + /// + /// The DataDirectory name. + /// + public const string DataDirectoryName = "DataDirectory"; + + /// + /// The DataDirectory placeholder. + /// + public const string DataDirectoryPlaceholder = "|DataDirectory|"; } } diff --git a/src/Umbraco.Core/Extensions/ConfigurationExtensions.cs b/src/Umbraco.Core/Extensions/ConfigurationExtensions.cs index 53f3b76c06..3dffdd8e67 100644 --- a/src/Umbraco.Core/Extensions/ConfigurationExtensions.cs +++ b/src/Umbraco.Core/Extensions/ConfigurationExtensions.cs @@ -10,16 +10,6 @@ namespace Umbraco.Extensions; /// public static class ConfigurationExtensions { - /// - /// The DataDirectory name. - /// - internal const string DataDirectoryName = "DataDirectory"; - - /// - /// The DataDirectory placeholder. - /// - internal const string DataDirectoryPlaceholder = "|DataDirectory|"; - /// /// The postfix used to identify a connection string provider setting. /// @@ -76,10 +66,10 @@ public static class ConfigurationExtensions if (!string.IsNullOrEmpty(connectionString)) { // Replace data directory - string? dataDirectory = AppDomain.CurrentDomain.GetData(DataDirectoryName)?.ToString(); + string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); if (!string.IsNullOrEmpty(dataDirectory)) { - connectionString = connectionString.Replace(DataDirectoryPlaceholder, dataDirectory); + connectionString = connectionString.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); } // Get provider name From b67c7fbe772037acdc7d806d7bb9576a7ef58b5b Mon Sep 17 00:00:00 2001 From: Sebastiaan Janssen Date: Mon, 22 May 2023 15:15:19 +0200 Subject: [PATCH 08/11] Cherry pick b8d6613bd8d382759c50cdab1bdd305d626642d6 accidentally kept both lines instead of just the one, this fixes the problem. --- templates/UmbracoProject/UmbracoProject.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/UmbracoProject/UmbracoProject.csproj b/templates/UmbracoProject/UmbracoProject.csproj index ea6db75798..7ae1dee47d 100644 --- a/templates/UmbracoProject/UmbracoProject.csproj +++ b/templates/UmbracoProject/UmbracoProject.csproj @@ -26,8 +26,6 @@ false false - - From 54af079d97be4c6c85bc536ed896f14284bde511 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Mon, 22 May 2023 15:18:14 +0200 Subject: [PATCH 09/11] Fix version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b465096390..829112f0e2 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.0.0-rc2", + "version": "12.1.0-rc", "assemblyVersion": { "precision": "build" }, From 7776cded7386ec1b435edb2c03779aa082edbdbf Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Mon, 22 May 2023 15:19:13 +0200 Subject: [PATCH 10/11] Bump version to next RC --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b465096390..2443aaa69a 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.0.0-rc2", + "version": "12.0.0-rc3", "assemblyVersion": { "precision": "build" }, From e79246368bdce66744da7b8d1cf225eb130bc62b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 23 May 2023 09:38:01 +0200 Subject: [PATCH 11/11] Update JSON schema package references for Forms and Deploy. (#14285) --- src/JsonSchema/JsonSchema.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index 3d31dd265d..613283bd69 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -12,7 +12,7 @@ - - + +