Files
Umbraco-CMS/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs
Ivo van der Bruggen c3e93f143c Fix nullability of return types that can be non-null (#15927)
* Fix nullability of Children extension

* Fix nullability of methods throughout the CMS

* Fix return types of some methods that cannot return null

* Revert nullable changes to result of ConvertSourceToIntermediate for property editors (whilst some property editors we know won't return null, it seems more consistent to adhere to the base class and interface nullability definition).

* Updated new webhook events to align with new nullability definitions.

* Reverted content editing service updates to align with base classes.

* Applied collection nullability updates on content repository to interface.

* Reverted value converter updates to match interface.

* Applied further collection updates to interface.

* Aligned media service interface with implementation for nullability.

* Update from code review.

---------

Co-authored-by: Ivo van der Bruggen <ivo@dutchbreeze.com>
Co-authored-by: Ivo van der Bruggen <ivo@vdbruggensoftware.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-07-30 14:19:20 +02:00

1244 lines
56 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Factories;
using Umbraco.Cms.Infrastructure.Persistence.Querying;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
using static Umbraco.Cms.Core.Persistence.SqlExtensionsStatics;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
{
internal sealed class ContentRepositoryBase
{
/// <summary>
/// This is used for unit tests ONLY
/// </summary>
public static bool ThrowOnWarning { get; set; } = false;
}
public abstract class ContentRepositoryBase<TId, TEntity, TRepository> : EntityRepositoryBase<TId, TEntity>, IContentRepository<TId, TEntity>
where TEntity : class, IContentBase
where TRepository : class, IRepository
{
private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories;
private readonly IEventAggregator _eventAggregator;
/// <summary>
///
/// </summary>
/// <param name="scopeAccessor"></param>
/// <param name="cache"></param>
/// <param name="logger"></param>
/// <param name="languageRepository"></param>
/// <param name="relationRepository"></param>
/// <param name="relationTypeRepository"></param>
/// <param name="dataValueReferenceFactories"></param>
/// <param name="dataTypeService"></param>
/// <param name="eventAggregator"></param>
/// <param name="propertyEditors">
/// Lazy property value collection - must be lazy because we have a circular dependency since some property editors require services, yet these services require property editors
/// </param>
protected ContentRepositoryBase(
IScopeAccessor scopeAccessor,
AppCaches cache,
ILogger<EntityRepositoryBase<TId, TEntity>> logger,
ILanguageRepository languageRepository,
IRelationRepository relationRepository,
IRelationTypeRepository relationTypeRepository,
PropertyEditorCollection propertyEditors,
DataValueReferenceFactoryCollection dataValueReferenceFactories,
IDataTypeService dataTypeService,
IEventAggregator eventAggregator)
: base(scopeAccessor, cache, logger)
{
DataTypeService = dataTypeService;
LanguageRepository = languageRepository;
RelationRepository = relationRepository;
RelationTypeRepository = relationTypeRepository;
PropertyEditors = propertyEditors;
_dataValueReferenceFactories = dataValueReferenceFactories;
_eventAggregator = eventAggregator;
}
protected abstract TRepository This { get; }
/// <summary>
/// Gets the node object type for the repository's entity
/// </summary>
protected abstract Guid NodeObjectTypeId { get; }
protected ILanguageRepository LanguageRepository { get; }
protected IDataTypeService DataTypeService { get; }
protected IRelationRepository RelationRepository { get; }
protected IRelationTypeRepository RelationTypeRepository { get; }
protected PropertyEditorCollection PropertyEditors { get; }
#region Versions
// gets a specific version
public abstract TEntity? GetVersion(int versionId);
// gets all versions, current first
public abstract IEnumerable<TEntity> GetAllVersions(int nodeId);
// gets all versions, current first
public virtual IEnumerable<TEntity> GetAllVersionsSlim(int nodeId, int skip, int take)
=> GetAllVersions(nodeId).Skip(skip).Take(take);
// gets all version ids, current first
public virtual IEnumerable<int> GetVersionIds(int nodeId, int maxRows)
{
SqlTemplate template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.GetVersionIds, tsql =>
tsql.Select<ContentVersionDto>(x => x.Id)
.From<ContentVersionDto>()
.Where<ContentVersionDto>(x => x.NodeId == SqlTemplate.Arg<int>("nodeId"))
.OrderByDescending<ContentVersionDto>(x => x.Current) // current '1' comes before others '0'
.AndByDescending<ContentVersionDto>(x => x.VersionDate)); // most recent first
return Database.Fetch<int>(SqlSyntax.SelectTop(template.Sql(nodeId), maxRows));
}
// deletes a specific version
public virtual void DeleteVersion(int versionId)
{
// TODO: test object node type?
// get the version we want to delete
SqlTemplate template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.GetVersion, tsql =>
tsql.Select<ContentVersionDto>().From<ContentVersionDto>().Where<ContentVersionDto>(x => x.Id == SqlTemplate.Arg<int>("versionId")));
ContentVersionDto? versionDto = Database.Fetch<ContentVersionDto>(template.Sql(new { versionId })).FirstOrDefault();
// nothing to delete
if (versionDto == null)
{
return;
}
// don't delete the current version
if (versionDto.Current)
{
throw new InvalidOperationException("Cannot delete the current version.");
}
PerformDeleteVersion(versionDto.NodeId, versionId);
}
// deletes all versions of an entity, older than a date.
public virtual void DeleteVersions(int nodeId, DateTime versionDate)
{
// TODO: test object node type?
// get the versions we want to delete, excluding the current one
SqlTemplate template = SqlContext.Templates.Get(
Constants.SqlTemplates.VersionableRepository.GetVersions,
tsql =>
tsql.Select<ContentVersionDto>()
.From<ContentVersionDto>()
.Where<ContentVersionDto>(x =>
x.NodeId == SqlTemplate.Arg<int>("nodeId") &&
!x.Current &&
x.VersionDate < SqlTemplate.Arg<DateTime>("versionDate")));
List<ContentVersionDto>? versionDtos = Database.Fetch<ContentVersionDto>(template.Sql(new { nodeId, versionDate }));
foreach (ContentVersionDto versionDto in versionDtos)
{
PerformDeleteVersion(versionDto.NodeId, versionDto.Id);
}
}
// actually deletes a version
protected abstract void PerformDeleteVersion(int id, int versionId);
#endregion
#region Count
/// <summary>
/// Count descendants of an item.
/// </summary>
public int CountDescendants(int parentId, string? contentTypeAlias = null)
{
var pathMatch = parentId == -1
? "-1,"
: "," + parentId + ",";
Sql<ISqlContext> sql = SqlContext.Sql()
.SelectCount()
.From<NodeDto>();
if (contentTypeAlias.IsNullOrWhiteSpace())
{
sql
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.Where<NodeDto>(x => x.Path.Contains(pathMatch));
}
else
{
sql
.InnerJoin<ContentDto>()
.On<NodeDto, ContentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<ContentTypeDto>()
.On<ContentTypeDto, ContentDto>(left => left.NodeId, right => right.ContentTypeId)
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.Where<NodeDto>(x => x.Path.Contains(pathMatch))
.Where<ContentTypeDto>(x => x.Alias == contentTypeAlias);
}
return Database.ExecuteScalar<int>(sql);
}
/// <summary>
/// Count children of an item.
/// </summary>
public int CountChildren(int parentId, string? contentTypeAlias = null)
{
Sql<ISqlContext> sql = SqlContext.Sql()
.SelectCount()
.From<NodeDto>();
if (contentTypeAlias.IsNullOrWhiteSpace())
{
sql
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.Where<NodeDto>(x => x.ParentId == parentId);
}
else
{
sql
.InnerJoin<ContentDto>()
.On<NodeDto, ContentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<ContentTypeDto>()
.On<ContentTypeDto, ContentDto>(left => left.NodeId, right => right.ContentTypeId)
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.Where<NodeDto>(x => x.ParentId == parentId)
.Where<ContentTypeDto>(x => x.Alias == contentTypeAlias);
}
return Database.ExecuteScalar<int>(sql);
}
/// <summary>
/// Count items.
/// </summary>
public int Count(string? contentTypeAlias = null)
{
Sql<ISqlContext> sql = SqlContext.Sql()
.SelectCount()
.From<NodeDto>();
if (contentTypeAlias.IsNullOrWhiteSpace())
{
sql
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId);
}
else
{
sql
.InnerJoin<ContentDto>()
.On<NodeDto, ContentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<ContentTypeDto>()
.On<ContentTypeDto, ContentDto>(left => left.NodeId, right => right.ContentTypeId)
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.Where<ContentTypeDto>(x => x.Alias == contentTypeAlias);
}
return Database.ExecuteScalar<int>(sql);
}
#endregion
#region Tags
/// <summary>
/// Updates tags for an item.
/// </summary>
protected void SetEntityTags(IContentBase entity, ITagRepository tagRepo, IJsonSerializer serializer)
{
foreach (IProperty property in entity.Properties)
{
if (PropertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out var editor) is false)
{
continue;
}
if (editor.GetValueEditor() is not IDataValueTags tagsProvider)
{
// support for legacy tag editors, everything from here down to the last continue can be removed when TagsPropertyEditorAttribute is removed
TagConfiguration? tagConfiguration = property.GetTagConfiguration(PropertyEditors, DataTypeService);
if (tagConfiguration == null)
{
continue;
}
if (property.PropertyType.VariesByCulture())
{
var tags = new List<ITag>();
foreach (IPropertyValue pvalue in property.Values)
{
IEnumerable<string> tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer, pvalue.Culture);
var languageId = LanguageRepository.GetIdByIsoCode(pvalue.Culture);
IEnumerable<Tag> cultureTags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x, LanguageId = languageId });
tags.AddRange(cultureTags);
}
tagRepo.Assign(entity.Id, property.PropertyTypeId, tags);
}
else
{
IEnumerable<string> tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer); // strings
IEnumerable<Tag> tags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x });
tagRepo.Assign(entity.Id, property.PropertyTypeId, tags);
}
continue; // not implementing IDataValueTags, continue
}
object? configurationObject = DataTypeService.GetDataType(property.PropertyType.DataTypeId)?.ConfigurationObject;
if (property.PropertyType.VariesByCulture())
{
var tags = new List<ITag>();
foreach (IPropertyValue pvalue in property.Values)
{
var languageId = LanguageRepository.GetIdByIsoCode(pvalue.Culture);
tags.AddRange(tagsProvider.GetTags(pvalue.EditedValue, configurationObject, languageId));
}
tagRepo.Assign(entity.Id, property.PropertyTypeId, tags);
}
else
{
IEnumerable<ITag> tags = tagsProvider.GetTags(property.GetValue(), configurationObject, null);
tagRepo.Assign(entity.Id, property.PropertyTypeId, tags);
}
}
}
// TODO: should we do it when un-publishing? or?
/// <summary>
/// Clears tags for an item.
/// </summary>
protected void ClearEntityTags(IContentBase entity, ITagRepository tagRepo)
{
tagRepo.RemoveAll(entity.Id);
}
#endregion
private Sql<ISqlContext> PreparePageSql(Sql<ISqlContext> sql, Sql<ISqlContext>? filterSql, Ordering ordering)
{
// non-filtering, non-ordering = nothing to do
if (filterSql == null && ordering.IsEmpty)
{
return sql;
}
// preserve original
var psql = new Sql<ISqlContext>(sql.SqlContext, sql.SQL, sql.Arguments);
// apply filter
if (filterSql != null)
{
psql.Append(filterSql);
}
// non-sorting, we're done
if (ordering.IsEmpty)
{
return psql;
}
// else apply ordering
ApplyOrdering(ref psql, ordering);
// no matter what we always MUST order the result also by umbracoNode.id to ensure that all records being ordered by are unique.
// if we do not do this then we end up with issues where we are ordering by a field that has duplicate values (i.e. the 'text' column
// is empty for many nodes) - see: http://issues.umbraco.org/issue/U4-8831
var (dbfield, _) = SqlContext.VisitDto<NodeDto>(x => x.NodeId);
if (ordering.IsCustomField || !ordering.OrderBy.InvariantEquals("id"))
{
psql.OrderBy(GetAliasedField(dbfield, sql));
}
// create prepared sql
// ensure it's single-line as NPoco PagingHelper has issues with multi-lines
psql = Sql(psql.SQL.ToSingleLine(), psql.Arguments);
// replace the magic culture parameter (see DocumentRepository.GetBaseQuery())
if (!ordering.Culture.IsNullOrWhiteSpace())
{
for (var i = 0; i < psql.Arguments.Length; i++)
{
if (psql.Arguments[i] is string s && s == "[[[ISOCODE]]]")
{
psql.Arguments[i] = ordering.Culture;
}
}
}
return psql;
}
private void ApplyOrdering(ref Sql<ISqlContext> sql, Ordering ordering)
{
if (sql == null)
{
throw new ArgumentNullException(nameof(sql));
}
if (ordering == null)
{
throw new ArgumentNullException(nameof(ordering));
}
var orderBy = ordering.IsCustomField
? ApplyCustomOrdering(ref sql, ordering)
: ApplySystemOrdering(ref sql, ordering);
// beware! NPoco paging code parses the query to isolate the ORDER BY fragment,
// using a regex that wants "([\w\.\[\]\(\)\s""`,]+)" - meaning that anything
// else in orderBy is going to break NPoco / not be detected
// beware! NPoco paging code (in PagingHelper) collapses everything [foo].[bar]
// to [bar] only, so we MUST use aliases, cannot use [table].[field]
// beware! pre-2012 SqlServer is using a convoluted syntax for paging, which
// includes "SELECT ROW_NUMBER() OVER (ORDER BY ...) poco_rn FROM SELECT (...",
// so anything added here MUST also be part of the inner SELECT statement, ie
// the original statement, AND must be using the proper alias, as the inner SELECT
// will hide the original table.field names entirely
if (ordering.Direction == Direction.Ascending)
{
sql.OrderBy(orderBy);
}
else
{
sql.OrderByDescending(orderBy);
}
}
protected virtual string ApplySystemOrdering(ref Sql<ISqlContext> sql, Ordering ordering)
{
// id is invariant
if (ordering.OrderBy.InvariantEquals("id"))
{
return GetAliasedField(SqlSyntax.GetFieldName<NodeDto>(x => x.NodeId), sql);
}
// sort order is invariant
if (ordering.OrderBy.InvariantEquals("sortOrder"))
{
return GetAliasedField(SqlSyntax.GetFieldName<NodeDto>(x => x.SortOrder), sql);
}
// path is invariant
if (ordering.OrderBy.InvariantEquals("path"))
{
return GetAliasedField(SqlSyntax.GetFieldName<NodeDto>(x => x.Path), sql);
}
// note: 'owner' is the user who created the item as a whole,
// we don't have an 'owner' per culture (should we?)
if (ordering.OrderBy.InvariantEquals("owner"))
{
Sql<ISqlContext> joins = Sql()
.InnerJoin<UserDto>("ownerUser").On<NodeDto, UserDto>((node, user) => node.UserId == user.Id, aliasRight: "ownerUser");
// see notes in ApplyOrdering: the field MUST be selected + aliased
sql = Sql(InsertBefore(sql, "FROM", ", " + SqlSyntax.GetFieldName<UserDto>(x => x.UserName, "ownerUser") + " AS ordering "), sql.Arguments);
sql = InsertJoins(sql, joins);
return "ordering";
}
// note: each version culture variation has a date too,
// maybe we would want to use it instead?
if (ordering.OrderBy.InvariantEquals("versionDate") || ordering.OrderBy.InvariantEquals("updateDate"))
{
return GetAliasedField(SqlSyntax.GetFieldName<ContentVersionDto>(x => x.VersionDate), sql);
}
// create date is invariant (we don't keep each culture's creation date)
if (ordering.OrderBy.InvariantEquals("createDate"))
{
return GetAliasedField(SqlSyntax.GetFieldName<NodeDto>(x => x.CreateDate), sql);
}
// name is variant
if (ordering.OrderBy.InvariantEquals("name"))
{
// no culture = can only work on the invariant name
// see notes in ApplyOrdering: the field MUST be aliased
if (ordering.Culture.IsNullOrWhiteSpace())
{
return GetAliasedField(SqlSyntax.GetFieldName<NodeDto>(x => x.Text!), sql);
}
// "variantName" alias is defined in DocumentRepository.GetBaseQuery
// TODO: what if it is NOT a document but a ... media or whatever?
// previously, we inserted the join+select *here* so we were sure to have it,
// but now that's not the case anymore!
return "variantName";
}
// content type alias is invariant
if (ordering.OrderBy.InvariantEquals("contentTypeAlias"))
{
Sql<ISqlContext> joins = Sql()
.InnerJoin<ContentTypeDto>("ctype").On<ContentDto, ContentTypeDto>((content, contentType) => content.ContentTypeId == contentType.NodeId, aliasRight: "ctype");
// see notes in ApplyOrdering: the field MUST be selected + aliased
sql = Sql(InsertBefore(sql, "FROM", ", " + SqlSyntax.GetFieldName<ContentTypeDto>(x => x.Alias!, "ctype") + " AS ordering "), sql.Arguments);
sql = InsertJoins(sql, joins);
return "ordering";
}
// previously, we'd accept anything and just sanitize it - not anymore
throw new NotSupportedException($"Ordering by {ordering.OrderBy} not supported.");
}
private string ApplyCustomOrdering(ref Sql<ISqlContext> sql, Ordering ordering)
{
// sorting by a custom field, so set-up sub-query for ORDER BY clause to pull through value
// from 'current' content version for the given order by field
var sortedInt = string.Format(SqlContext.SqlSyntax.ConvertIntegerToOrderableString, "intValue");
var sortedDecimal = string.Format(SqlContext.SqlSyntax.ConvertDecimalToOrderableString, "decimalValue");
var sortedDate = string.Format(SqlContext.SqlSyntax.ConvertDateToOrderableString, "dateValue");
var sortedString = "COALESCE(varcharValue,'')"; // assuming COALESCE is ok for all syntaxes
// needs to be an outer join since there's no guarantee that any of the nodes have values for this property
Sql<ISqlContext> innerSql = Sql().Select($@"CASE
WHEN intValue IS NOT NULL THEN {sortedInt}
WHEN decimalValue IS NOT NULL THEN {sortedDecimal}
WHEN dateValue IS NOT NULL THEN {sortedDate}
ELSE {sortedString}
END AS customPropVal,
cver.nodeId AS customPropNodeId")
.From<ContentVersionDto>("cver")
.InnerJoin<PropertyDataDto>("opdata")
.On<ContentVersionDto, PropertyDataDto>((version, pdata) => version.Id == pdata.VersionId, "cver", "opdata")
.InnerJoin<PropertyTypeDto>("optype").On<PropertyDataDto, PropertyTypeDto>((pdata, ptype) => pdata.PropertyTypeId == ptype.Id, "opdata", "optype")
.LeftJoin<LanguageDto>().On<PropertyDataDto, LanguageDto>((pdata, lang) => pdata.LanguageId == lang.Id, "opdata")
.Where<ContentVersionDto>(x => x.Current, "cver") // always query on current (edit) values
.Where<PropertyTypeDto>(x => x.Alias == ordering.OrderBy, "optype")
.Where<PropertyDataDto, LanguageDto>((opdata, lang) => opdata.LanguageId == null || lang.IsoCode == ordering.Culture, "opdata");
// merge arguments
var argsList = sql.Arguments.ToList();
var innerSqlString = ParameterHelper.ProcessParams(innerSql.SQL, innerSql.Arguments, argsList);
// create the outer join complete sql fragment
var outerJoinTempTable = $@"LEFT OUTER JOIN ({innerSqlString}) AS customPropData
ON customPropData.customPropNodeId = {Constants.DatabaseSchema.Tables.Node}.id "; // trailing space is important!
// insert this just above the first WHERE
var newSql = InsertBefore(sql.SQL, "WHERE", outerJoinTempTable);
// see notes in ApplyOrdering: the field MUST be selected + aliased
newSql = InsertBefore(newSql, "FROM", ", customPropData.customPropVal AS ordering "); // trailing space is important!
// create the new sql
sql = Sql(newSql, argsList.ToArray());
// and order by the custom field
// this original code means that an ascending sort would first expose all NULL values, ie items without a value
return "ordering";
// note: adding an extra sorting criteria on
// "(CASE WHEN customPropData.customPropVal IS NULL THEN 1 ELSE 0 END")
// would ensure that items without a value always come last, both in ASC and DESC-ending sorts
}
public abstract IEnumerable<TEntity> GetPage(IQuery<TEntity>? query, long pageIndex, int pageSize, out long totalRecords, IQuery<TEntity>? filter, Ordering? ordering);
public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportOptions options)
{
var report = new Dictionary<int, ContentDataIntegrityReportEntry>();
Sql<ISqlContext> sql = SqlContext.Sql()
.Select<NodeDto>()
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId)
.OrderBy<NodeDto>(x => x.Level, x => x.ParentId, x => x.SortOrder);
var nodesToRebuild = new Dictionary<int, List<NodeDto>>();
var validNodes = new Dictionary<int, NodeDto>();
var rootIds = new[] { Constants.System.Root, Constants.System.RecycleBinContent, Constants.System.RecycleBinMedia };
var currentParentIds = new HashSet<int>(rootIds);
HashSet<int> prevParentIds = currentParentIds;
var lastLevel = -1;
// use a forward cursor (query)
foreach (NodeDto? node in Database.Query<NodeDto>(sql))
{
if (node.Level != lastLevel)
{
// changing levels
prevParentIds = currentParentIds;
currentParentIds = null;
lastLevel = node.Level;
}
if (currentParentIds == null)
{
// we're reset
currentParentIds = new HashSet<int>();
}
currentParentIds.Add(node.NodeId);
// paths parts without the roots
var pathParts = node.Path.Split(Constants.CharArrays.Comma).Where(x => !rootIds.Contains(int.Parse(x, CultureInfo.InvariantCulture))).ToArray();
if (!prevParentIds.Contains(node.ParentId))
{
// invalid, this will be because the level is wrong (which prob means path is wrong too)
report.Add(node.NodeId, new ContentDataIntegrityReportEntry(ContentDataIntegrityReport.IssueType.InvalidPathAndLevelByParentId));
AppendNodeToFix(nodesToRebuild, node);
}
else if (pathParts.Length == 0)
{
// invalid path
report.Add(node.NodeId, new ContentDataIntegrityReportEntry(ContentDataIntegrityReport.IssueType.InvalidPathEmpty));
AppendNodeToFix(nodesToRebuild, node);
}
else if (pathParts.Length != node.Level)
{
// invalid, either path or level is wrong
report.Add(node.NodeId, new ContentDataIntegrityReportEntry(ContentDataIntegrityReport.IssueType.InvalidPathLevelMismatch));
AppendNodeToFix(nodesToRebuild, node);
}
else if (pathParts[pathParts.Length - 1] != node.NodeId.ToString())
{
// invalid path
report.Add(node.NodeId, new ContentDataIntegrityReportEntry(ContentDataIntegrityReport.IssueType.InvalidPathById));
AppendNodeToFix(nodesToRebuild, node);
}
else if (!rootIds.Contains(node.ParentId) && pathParts[pathParts.Length - 2] != node.ParentId.ToString())
{
// invalid path
report.Add(node.NodeId, new ContentDataIntegrityReportEntry(ContentDataIntegrityReport.IssueType.InvalidPathByParentId));
AppendNodeToFix(nodesToRebuild, node);
}
else
{
// it's valid!
// don't track unless we are configured to fix
if (options.FixIssues)
{
validNodes.Add(node.NodeId, node);
}
}
}
var updated = new List<NodeDto>();
if (options.FixIssues)
{
// iterate all valid nodes to see if these are parents for invalid nodes
foreach (var (nodeId, node) in validNodes)
{
if (!nodesToRebuild.TryGetValue(nodeId, out List<NodeDto>? invalidNodes))
{
continue;
}
// now we can try to rebuild the invalid paths.
foreach (NodeDto invalidNode in invalidNodes)
{
invalidNode.Level = (short)(node.Level + 1);
invalidNode.Path = node.Path + "," + invalidNode.NodeId;
updated.Add(invalidNode);
}
}
foreach (NodeDto node in updated)
{
Database.Update(node);
if (report.TryGetValue(node.NodeId, out ContentDataIntegrityReportEntry? entry))
{
entry.Fixed = true;
}
}
}
return new ContentDataIntegrityReport(report);
}
private static void AppendNodeToFix(IDictionary<int, List<NodeDto>> nodesToRebuild, NodeDto node)
{
if (nodesToRebuild.TryGetValue(node.ParentId, out List<NodeDto>? childIds))
{
childIds.Add(node);
}
else
{
nodesToRebuild[node.ParentId] = new List<NodeDto> { node };
}
}
// here, filter can be null and ordering cannot
protected IEnumerable<TEntity> GetPage<TDto>(
IQuery<TEntity>? query,
long pageIndex,
int pageSize,
out long totalRecords,
Func<List<TDto>, IEnumerable<TEntity>> mapDtos,
Sql<ISqlContext>? filter,
Ordering? ordering)
{
if (ordering == null)
{
throw new ArgumentNullException(nameof(ordering));
}
// start with base query, and apply the supplied IQuery
if (query == null)
{
query = Query<TEntity>();
}
Sql<ISqlContext> sql = new SqlTranslator<TEntity>(GetBaseQuery(QueryType.Many), query).Translate();
// sort and filter
sql = PreparePageSql(sql, filter, ordering);
// get a page of DTOs and the total count
Page<TDto>? pagedResult = Database.Page<TDto>(pageIndex + 1, pageSize, sql);
totalRecords = Convert.ToInt32(pagedResult.TotalItems);
// map the DTOs and return
return mapDtos(pagedResult.Items);
}
protected IDictionary<int, PropertyCollection> GetPropertyCollections<T>(List<TempContent<T>> temps)
where T : class, IContentBase
{
var versions = new List<int>();
foreach (TempContent<T> temp in temps)
{
versions.Add(temp.VersionId);
if (temp.PublishedVersionId > 0)
{
versions.Add(temp.PublishedVersionId);
}
}
if (versions.Count == 0)
{
return new Dictionary<int, PropertyCollection>();
}
// TODO: This is a bugger of a query and I believe is the main issue with regards to SQL performance drain when querying content
// which is done when rebuilding caches/indexes/etc... in bulk. We are using an "IN" query on umbracoPropertyData.VersionId
// which then performs a Clustered Index Scan on PK_umbracoPropertyData which means it iterates the entire table which can be enormous!
// especially if there are both a lot of content but worse if there is a lot of versions of that content.
// So is it possible to return this property data without doing an index scan on PK_umbracoPropertyData and without iterating every row
// in the table?
// get all PropertyDataDto for all definitions / versions
var allPropertyDataDtos = Database.FetchByGroups<PropertyDataDto, int>(versions, Constants.Sql.MaxParameterCount, batch =>
SqlContext.Sql()
.Select<PropertyDataDto>()
.From<PropertyDataDto>()
.WhereIn<PropertyDataDto>(x => x.VersionId, batch))
.ToList();
// get PropertyDataDto distinct PropertyTypeDto
var allPropertyTypeIds = allPropertyDataDtos.Select(x => x.PropertyTypeId).Distinct().ToList();
IEnumerable<PropertyTypeDto> allPropertyTypeDtos = Database.FetchByGroups<PropertyTypeDto, int>(allPropertyTypeIds, Constants.Sql.MaxParameterCount, batch =>
SqlContext.Sql()
.Select<PropertyTypeDto>(r => r.Select(x => x.DataTypeDto))
.From<PropertyTypeDto>()
.InnerJoin<DataTypeDto>().On<PropertyTypeDto, DataTypeDto>((left, right) => left.DataTypeId == right.NodeId)
.WhereIn<PropertyTypeDto>(x => x.Id, batch));
// index the types for perfs, and assign to PropertyDataDto
var indexedPropertyTypeDtos = allPropertyTypeDtos.ToDictionary(x => x.Id, x => x);
foreach (PropertyDataDto a in allPropertyDataDtos)
{
a.PropertyTypeDto = indexedPropertyTypeDtos[a.PropertyTypeId];
}
// now we have
// - the definitions
// - all property data dtos
// - tag editors (Actually ... no we don't since i removed that code, but we don't need them anyways it seems)
// and we need to build the proper property collections
return GetPropertyCollections(temps, allPropertyDataDtos);
}
private IDictionary<int, PropertyCollection> GetPropertyCollections<T>(List<TempContent<T>> temps, IEnumerable<PropertyDataDto> allPropertyDataDtos)
where T : class, IContentBase
{
var result = new Dictionary<int, PropertyCollection>();
var compositionPropertiesIndex = new Dictionary<int, IPropertyType[]>();
// index PropertyDataDto per versionId for perfs
// merge edited and published dtos
var indexedPropertyDataDtos = new Dictionary<int, List<PropertyDataDto>>();
foreach (PropertyDataDto dto in allPropertyDataDtos)
{
var versionId = dto.VersionId;
if (indexedPropertyDataDtos.TryGetValue(versionId, out List<PropertyDataDto>? list) == false)
{
indexedPropertyDataDtos[versionId] = list = new List<PropertyDataDto>();
}
list.Add(dto);
}
foreach (TempContent<T> temp in temps)
{
// compositionProperties is the property types for the entire composition
// use an index for perfs
if (temp.ContentType is null)
{
continue;
}
if (compositionPropertiesIndex.TryGetValue(temp.ContentType.Id, out IPropertyType[]? compositionProperties) == false)
{
compositionPropertiesIndex[temp.ContentType.Id] = compositionProperties = temp.ContentType.CompositionPropertyTypes.ToArray();
}
// map the list of PropertyDataDto to a list of Property
var propertyDataDtos = new List<PropertyDataDto>();
if (indexedPropertyDataDtos.TryGetValue(temp.VersionId, out List<PropertyDataDto>? propertyDataDtos1))
{
propertyDataDtos.AddRange(propertyDataDtos1);
// dirty corner case
if (temp.VersionId == temp.PublishedVersionId)
{
propertyDataDtos.AddRange(propertyDataDtos1.Select(x => x.Clone(-1)));
}
}
if (temp.VersionId != temp.PublishedVersionId && indexedPropertyDataDtos.TryGetValue(temp.PublishedVersionId, out List<PropertyDataDto>? propertyDataDtos2))
{
propertyDataDtos.AddRange(propertyDataDtos2);
}
var properties = PropertyFactory.BuildEntities(compositionProperties, propertyDataDtos, temp.PublishedVersionId, LanguageRepository).ToList();
if (result.ContainsKey(temp.VersionId))
{
if (ContentRepositoryBase.ThrowOnWarning)
{
throw new InvalidOperationException($"The query returned multiple property sets for content {temp.Id}, {temp.ContentType.Name}");
}
Logger.LogWarning("The query returned multiple property sets for content {ContentId}, {ContentTypeName}", temp.Id, temp.ContentType.Name);
}
result[temp.VersionId] = new PropertyCollection(properties);
}
return result;
}
protected string InsertBefore(Sql<ISqlContext> s, string atToken, string insert)
=> InsertBefore(s.SQL, atToken, insert);
protected string InsertBefore(string s, string atToken, string insert)
{
var pos = s.InvariantIndexOf(atToken);
if (pos < 0)
{
throw new Exception($"Could not find token \"{atToken}\".");
}
return s.Insert(pos, insert);
}
protected Sql<ISqlContext> InsertJoins(Sql<ISqlContext> sql, Sql<ISqlContext> joins)
{
var joinsSql = joins.SQL;
var args = sql.Arguments;
// merge args if any
if (joins.Arguments.Length > 0)
{
var argsList = args.ToList();
joinsSql = ParameterHelper.ProcessParams(joinsSql, joins.Arguments, argsList);
args = argsList.ToArray();
}
return Sql(InsertBefore(sql.SQL, "WHERE", joinsSql), args);
}
private string GetAliasedField(string field, Sql sql)
{
// get alias, if aliased
//
// regex looks for pattern "([\w+].[\w+]) AS ([\w+])" ie "(field) AS (alias)"
// and, if found & a group's field matches the field name, returns the alias
//
// so... if query contains "[umbracoNode].[nodeId] AS [umbracoNode__nodeId]"
// then GetAliased for "[umbracoNode].[nodeId]" returns "[umbracoNode__nodeId]"
MatchCollection matches = SqlContext.SqlSyntax.AliasRegex.Matches(sql.SQL);
Match? match = matches.Cast<Match>().FirstOrDefault(m => m.Groups[1].Value.InvariantEquals(field));
return match == null ? field : match.Groups[2].Value;
}
protected string GetQuotedFieldName(string tableName, string fieldName)
{
return SqlContext.SqlSyntax.GetQuotedTableName(tableName) + "." + SqlContext.SqlSyntax.GetQuotedColumnName(fieldName);
}
#region UnitOfWork Notification
/*
* The reason why EntityRefreshNotification is published from the repository and not the service is because
* the published state of the IContent must be "Publishing" when the event is raised for the cache to handle it correctly.
* This state is changed half way through the repository method, meaning that if we publish the notification
* after the state will be "Published" and the cache won't handle it correctly,
* It wont call OnRepositoryRefreshed with the published flag set to true, the same is true for unpublishing
* where it wont remove the entity from the nucache.
* We can't publish the notification before calling Save method on the repository either,
* because that method sets certain fields such as create date, update date, etc...
*/
/// <summary>
/// Publishes a notification, used to publish <see cref="EntityRefreshNotification{T}"/> for caching purposes.
/// </summary>
protected void OnUowRefreshedEntity(INotification notification) => _eventAggregator.Publish(notification);
protected void OnUowRemovingEntity(IContentBase entity) => _eventAggregator.Publish(new ScopedEntityRemoveNotification(entity, new EventMessages()));
#endregion
#region Classes
protected class TempContent
{
public TempContent(int id, int versionId, int publishedVersionId, IContentTypeComposition? contentType)
{
Id = id;
VersionId = versionId;
PublishedVersionId = publishedVersionId;
ContentType = contentType;
}
/// <summary>
/// Gets or sets the identifier of the content.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the version identifier of the content.
/// </summary>
public int VersionId { get; set; }
/// <summary>
/// Gets or sets the published version identifier of the content.
/// </summary>
public int PublishedVersionId { get; set; }
/// <summary>
/// Gets or sets the content type.
/// </summary>
public IContentTypeComposition? ContentType { get; set; }
/// <summary>
/// Gets or sets the identifier of the template 1 of the content.
/// </summary>
public int? Template1Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the template 2 of the content.
/// </summary>
public int? Template2Id { get; set; }
}
protected class TempContent<T> : TempContent
where T : class, IContentBase
{
public TempContent(int id, int versionId, int publishedVersionId, IContentTypeComposition? contentType, T? content = null)
: base(id, versionId, publishedVersionId, contentType)
{
Content = content;
}
/// <summary>
/// Gets or sets the associated actual content.
/// </summary>
public T? Content { get; set; }
}
/// <summary>
/// For Paging, repositories must support returning different query for the query type specified
/// </summary>
/// <param name="queryType"></param>
/// <returns></returns>
protected abstract Sql<ISqlContext> GetBaseQuery(QueryType queryType);
#endregion
#region Utilities
protected virtual string? EnsureUniqueNodeName(int parentId, string? nodeName, int id = 0)
{
SqlTemplate? template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.EnsureUniqueNodeName, tsql => tsql
.Select<NodeDto>(x => Alias(x.NodeId, "id"), x => Alias(x.Text!, "name"))
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeObjectType == SqlTemplate.Arg<Guid>("nodeObjectType") && x.ParentId == SqlTemplate.Arg<int>("parentId")));
Sql<ISqlContext> sql = template.Sql(NodeObjectTypeId, parentId);
List<SimilarNodeName>? names = Database.Fetch<SimilarNodeName>(sql);
return SimilarNodeName.GetUniqueName(names, id, nodeName);
}
protected virtual bool SortorderExists(int parentId, int sortOrder)
{
SqlTemplate? template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.SortOrderExists, tsql => tsql
.Select("sortOrder")
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeObjectType == SqlTemplate.Arg<Guid>("nodeObjectType") &&
x.ParentId == SqlTemplate.Arg<int>("parentId") &&
x.SortOrder == SqlTemplate.Arg<int>("sortOrder")));
Sql<ISqlContext> sql = template.Sql(NodeObjectTypeId, parentId, sortOrder);
var result = Database.ExecuteScalar<int?>(sql);
return result != null;
}
protected virtual int GetNewChildSortOrder(int parentId, int first)
{
SqlTemplate? template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.GetSortOrder, tsql => tsql
.Select("MAX(sortOrder)")
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeObjectType == SqlTemplate.Arg<Guid>("nodeObjectType") && x.ParentId == SqlTemplate.Arg<int>("parentId")));
Sql<ISqlContext> sql = template.Sql(NodeObjectTypeId, parentId);
var sortOrder = Database.ExecuteScalar<int?>(sql);
return sortOrder + 1 ?? first;
}
protected virtual NodeDto GetParentNodeDto(int parentId)
{
SqlTemplate? template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.GetParentNode, tsql => tsql
.Select<NodeDto>()
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeId == SqlTemplate.Arg<int>("parentId")));
Sql<ISqlContext> sql = template.Sql(parentId);
NodeDto? nodeDto = Database.First<NodeDto>(sql);
return nodeDto;
}
protected virtual int GetReservedId(Guid uniqueId)
{
SqlTemplate template = SqlContext.Templates.Get(Constants.SqlTemplates.VersionableRepository.GetReservedId, tsql => tsql
.Select<NodeDto>(x => x.NodeId)
.From<NodeDto>()
.Where<NodeDto>(x => x.UniqueId == SqlTemplate.Arg<Guid>("uniqueId") && x.NodeObjectType == Constants.ObjectTypes.IdReservation));
Sql<ISqlContext> sql = template.Sql(new { uniqueId });
var id = Database.ExecuteScalar<int?>(sql);
return id ?? 0;
}
#endregion
#region Recycle bin
public abstract int RecycleBinId { get; }
public virtual IEnumerable<TEntity> GetRecycleBin()
{
return Get(Query<TEntity>().Where(entity => entity.Trashed));
}
#endregion
protected void PersistRelations(TEntity entity)
{
// Get all references and automatic relation type aliases
ISet<UmbracoEntityReference> references = _dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors);
ISet<string> automaticRelationTypeAliases = _dataValueReferenceFactories.GetAllAutomaticRelationTypesAliases(PropertyEditors);
if (references.Count == 0)
{
// Delete all relations using the automatic relation type aliases
RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray());
// No need to add new references/relations
return;
}
// Lookup all relation type IDs
var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty<int>())
.Where(x => automaticRelationTypeAliases.Contains(x.Alias))
.ToDictionary(x => x.Alias, x => x.Id);
// Lookup node IDs for all GUID based UDIs
IEnumerable<Guid> keys = references.Select(x => x.Udi).OfType<GuidUdi>().Select(x => x.Guid);
var keysLookup = Database.FetchByGroups<NodeIdKey, Guid>(keys, Constants.Sql.MaxParameterCount, guids =>
{
return Sql()
.Select<NodeDto>(x => x.NodeId, x => x.UniqueId)
.From<NodeDto>()
.WhereIn<NodeDto>(x => x.UniqueId, guids);
}).ToDictionary(x => x.UniqueId, x => x.NodeId);
// Get all valid relations
var relations = new List<(int ChildId, int RelationTypeId)>(references.Count);
foreach (UmbracoEntityReference reference in references)
{
if (string.IsNullOrEmpty(reference.RelationTypeAlias))
{
// Reference does not specify a relation type alias, so skip adding a relation
Logger.LogDebug("The reference to {Udi} does not specify a relation type alias, so it will not be saved as relation.", reference.Udi);
}
else if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias))
{
// Returning a reference that doesn't use an automatic relation type is an issue that should be fixed in code
Logger.LogError("The reference to {Udi} uses a relation type {RelationTypeAlias} that is not an automatic relation type.", reference.Udi, reference.RelationTypeAlias);
}
else if (!relationTypeLookup.TryGetValue(reference.RelationTypeAlias, out int relationTypeId))
{
// A non-existent relation type could be caused by an environment issue (e.g. it was manually removed)
Logger.LogWarning("The reference to {Udi} uses a relation type {RelationTypeAlias} that does not exist.", reference.Udi, reference.RelationTypeAlias);
}
else if (reference.Udi is not GuidUdi udi || !keysLookup.TryGetValue(udi.Guid, out var id))
{
// Relations only support references to items that are stored in the NodeDto table (because of foreign key constraints)
Logger.LogInformation("The reference to {Udi} can not be saved as relation, because doesn't have a node ID.", reference.Udi);
}
else
{
relations.Add((id, relationTypeId));
}
}
// Get all existing relations (optimize for adding new and keeping existing relations)
var query = Query<IRelation>().Where(x => x.ParentId == entity.Id).WhereIn(x => x.RelationTypeId, relationTypeLookup.Values);
var existingRelations = RelationRepository.GetPagedRelationsByQuery(query, 0, int.MaxValue, out _, null)
.ToDictionary(x => (x.ChildId, x.RelationTypeId)); // Relations are unique by parent ID, child ID and relation type ID
// Add relations that don't exist yet
var relationsToAdd = relations.Except(existingRelations.Keys).Select(x => new ReadOnlyRelation(entity.Id, x.ChildId, x.RelationTypeId));
RelationRepository.SaveBulk(relationsToAdd);
// Delete relations that don't exist anymore
foreach (IRelation relation in existingRelations.Where(x => !relations.Contains(x.Key)).Select(x => x.Value))
{
RelationRepository.Delete(relation);
}
}
/// <summary>
/// Inserts property values for the content entity
/// </summary>
/// <param name="entity"></param>
/// <param name="publishedVersionId"></param>
/// <param name="edited"></param>
/// <param name="editedCultures"></param>
/// <remarks>
/// Used when creating a new entity
/// </remarks>
protected void InsertPropertyValues(TEntity entity, int publishedVersionId, out bool edited, out HashSet<string>? editedCultures)
{
// persist the property data
IEnumerable<PropertyDataDto> propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishedVersionId, entity.Properties, LanguageRepository, out edited, out editedCultures);
foreach (PropertyDataDto? propertyDataDto in propertyDataDtos)
{
Database.Insert(propertyDataDto);
}
// TODO: we can speed this up: Use BulkInsert and then do one SELECT to re-retrieve the property data inserted with assigned IDs.
// This is a perfect thing to benchmark with Benchmark.NET to compare perf between Nuget releases.
}
/// <summary>
/// Used to atomically replace the property values for the entity version specified
/// </summary>
/// <param name="entity"></param>
/// <param name="versionId"></param>
/// <param name="publishedVersionId"></param>
/// <param name="edited"></param>
/// <param name="editedCultures"></param>
protected void ReplacePropertyValues(TEntity entity, int versionId, int publishedVersionId, out bool edited, out HashSet<string>? editedCultures)
{
// Replace the property data.
// Lookup the data to update with a UPDLOCK (using ForUpdate()) this is because we need to be atomic
// and handle DB concurrency. Doing a clear and then re-insert is prone to concurrency issues.
Sql<ISqlContext> propDataSql = SqlContext.Sql().Select("*").From<PropertyDataDto>().Where<PropertyDataDto>(x => x.VersionId == versionId).ForUpdate();
List<PropertyDataDto>? existingPropData = Database.Fetch<PropertyDataDto>(propDataSql);
var propertyTypeToPropertyData = new Dictionary<(int propertyTypeId, int versionId, int? languageId, string? segment), PropertyDataDto>();
var existingPropDataIds = new List<int>();
foreach (PropertyDataDto? p in existingPropData)
{
existingPropDataIds.Add(p.Id);
propertyTypeToPropertyData[(p.PropertyTypeId, p.VersionId, p.LanguageId, p.Segment)] = p;
}
IEnumerable<PropertyDataDto> propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, publishedVersionId, entity.Properties, LanguageRepository, out edited, out editedCultures);
foreach (PropertyDataDto propertyDataDto in propertyDataDtos)
{
// Check if this already exists and update, else insert a new one
if (propertyTypeToPropertyData.TryGetValue((propertyDataDto.PropertyTypeId, propertyDataDto.VersionId, propertyDataDto.LanguageId, propertyDataDto.Segment), out PropertyDataDto? propData))
{
propertyDataDto.Id = propData.Id;
Database.Update(propertyDataDto);
}
else
{
// TODO: we can speed this up: Use BulkInsert and then do one SELECT to re-retrieve the property data inserted with assigned IDs.
// This is a perfect thing to benchmark with Benchmark.NET to compare perf between Nuget releases.
Database.Insert(propertyDataDto);
}
// track which ones have been processed
existingPropDataIds.Remove(propertyDataDto.Id);
}
// For any remaining that haven't been processed they need to be deleted
if (existingPropDataIds.Count > 0)
{
Database.Execute(SqlContext.Sql().Delete<PropertyDataDto>().WhereIn<PropertyDataDto>(x => x.Id, existingPropDataIds));
}
}
private sealed class NodeIdKey
{
[Column("id")]
public int NodeId { get; set; }
[Column("uniqueId")]
public Guid UniqueId { get; set; }
}
}
}