Files
Umbraco-CMS/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs
Sven Geusens 9febbc7db1 Distinguish between default zero and intentional zero sort order for new documents (#17517)
* Distinguish between default value and initial zero

* Update special value comment documentation

* Redid solution with dirty/new entity tracking

* rework copy branch sortorder fix

* Change == false to is false

---------

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
2024-11-21 09:09:25 +01:00

1805 lines
76 KiB
C#

using System.Globalization;
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.Membership;
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.Persistence.SqlSyntax;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
/// <summary>
/// Represents a repository for doing CRUD operations for <see cref="IContent" />.
/// </summary>
public class DocumentRepository : ContentRepositoryBase<int, IContent, DocumentRepository>, IDocumentRepository
{
private readonly AppCaches _appCaches;
private readonly ContentByGuidReadRepository _contentByGuidReadRepository;
private readonly IContentTypeRepository _contentTypeRepository;
private readonly ILoggerFactory _loggerFactory;
private readonly IScopeAccessor _scopeAccessor;
private readonly IJsonSerializer _serializer;
private readonly ITagRepository _tagRepository;
private readonly ITemplateRepository _templateRepository;
private PermissionRepository<IContent>? _permissionRepository;
/// <summary>
/// Constructor
/// </summary>
/// <param name="scopeAccessor"></param>
/// <param name="appCaches"></param>
/// <param name="logger"></param>
/// <param name="loggerFactory"></param>
/// <param name="contentTypeRepository"></param>
/// <param name="templateRepository"></param>
/// <param name="tagRepository"></param>
/// <param name="languageRepository"></param>
/// <param name="relationRepository"></param>
/// <param name="relationTypeRepository"></param>
/// <param name="dataValueReferenceFactories"></param>
/// <param name="dataTypeService"></param>
/// <param name="serializer"></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>
public DocumentRepository(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
ILogger<DocumentRepository> logger,
ILoggerFactory loggerFactory,
IContentTypeRepository contentTypeRepository,
ITemplateRepository templateRepository,
ITagRepository tagRepository,
ILanguageRepository languageRepository,
IRelationRepository relationRepository,
IRelationTypeRepository relationTypeRepository,
PropertyEditorCollection propertyEditors,
DataValueReferenceFactoryCollection dataValueReferenceFactories,
IDataTypeService dataTypeService,
IJsonSerializer serializer,
IEventAggregator eventAggregator)
: base(scopeAccessor, appCaches, logger, languageRepository, relationRepository, relationTypeRepository,
propertyEditors, dataValueReferenceFactories, dataTypeService, eventAggregator)
{
_contentTypeRepository =
contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository));
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
_tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository));
_serializer = serializer;
_appCaches = appCaches;
_loggerFactory = loggerFactory;
_scopeAccessor = scopeAccessor;
_contentByGuidReadRepository = new ContentByGuidReadRepository(this, scopeAccessor, appCaches,
loggerFactory.CreateLogger<ContentByGuidReadRepository>());
}
protected override DocumentRepository This => this;
/// <summary>
/// Default is to always ensure all documents have unique names
/// </summary>
protected virtual bool EnsureUniqueNaming { get; } = true;
// note: is ok to 'new' the repo here as it's a sub-repo really
private PermissionRepository<IContent> PermissionRepository => _permissionRepository
?? (_permissionRepository =
new PermissionRepository<IContent>(
_scopeAccessor, _appCaches,
_loggerFactory
.CreateLogger<
PermissionRepository<IContent>>()));
/// <inheritdoc />
public ContentScheduleCollection GetContentSchedule(int contentId)
{
var result = new ContentScheduleCollection();
List<ContentScheduleDto>? scheduleDtos = Database.Fetch<ContentScheduleDto>(Sql()
.Select<ContentScheduleDto>()
.From<ContentScheduleDto>()
.Where<ContentScheduleDto>(x => x.NodeId == contentId));
foreach (ContentScheduleDto? scheduleDto in scheduleDtos)
{
result.Add(new ContentSchedule(scheduleDto.Id,
LanguageRepository.GetIsoCodeById(scheduleDto.LanguageId) ?? string.Empty,
scheduleDto.Date,
scheduleDto.Action == ContentScheduleAction.Release.ToString()
? ContentScheduleAction.Release
: ContentScheduleAction.Expire));
}
return result;
}
protected override string ApplySystemOrdering(ref Sql<ISqlContext> sql, Ordering ordering)
{
// note: 'updater' is the user who created the latest draft version,
// we don't have an 'updater' per culture (should we?)
if (ordering.OrderBy.InvariantEquals("updater"))
{
Sql<ISqlContext> joins = Sql()
.InnerJoin<UserDto>("updaterUser")
.On<ContentVersionDto, UserDto>((version, user) => version.UserId == user.Id,
aliasRight: "updaterUser");
// see notes in ApplyOrdering: the field MUST be selected + aliased
sql = Sql(
InsertBefore(sql, "FROM",
", " + SqlSyntax.GetFieldName<UserDto>(x => x.UserName, "updaterUser") + " AS ordering "),
sql.Arguments);
sql = InsertJoins(sql, joins);
return "ordering";
}
if (ordering.OrderBy.InvariantEquals("published"))
{
// no culture, assume invariant and simply order by published.
if (ordering.Culture.IsNullOrWhiteSpace())
{
return SqlSyntax.GetFieldName<DocumentDto>(x => x.Published);
}
// invariant: left join will yield NULL and we must use pcv to determine published
// variant: left join may yield NULL or something, and that determines published
Sql<ISqlContext> joins = Sql()
.InnerJoin<ContentTypeDto>("ctype").On<ContentDto, ContentTypeDto>(
(content, contentType) => content.ContentTypeId == contentType.NodeId, aliasRight: "ctype")
// left join on optional culture variation
//the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code
.LeftJoin<ContentVersionCultureVariationDto>(nested =>
nested.InnerJoin<LanguageDto>("langp").On<ContentVersionCultureVariationDto, LanguageDto>(
(ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccvp",
"langp"),
"ccvp")
.On<ContentVersionDto, ContentVersionCultureVariationDto>((version, ccv) => version.Id == ccv.VersionId,
"pcv", "ccvp");
sql = InsertJoins(sql, joins);
// see notes in ApplyOrdering: the field MUST be selected + aliased, and we cannot have
// the whole CASE fragment in ORDER BY due to it not being detected by NPoco
var sqlText = InsertBefore(sql.SQL, "FROM",
// when invariant, ie 'variations' does not have the culture flag (value 1), it should be safe to simply use the published flag on umbracoDocument,
// otherwise check if there's a version culture variation for the lang, via ccv.id
$", (CASE WHEN (ctype.variations & 1) = 0 THEN ({SqlSyntax.GetFieldName<DocumentDto>(x => x.Published)}) ELSE (CASE WHEN ccvp.id IS NULL THEN 0 ELSE 1 END) END) AS ordering "); // trailing space is important!
sql = Sql(sqlText, sql.Arguments);
return "ordering";
}
return base.ApplySystemOrdering(ref sql, ordering);
}
private IEnumerable<IContent> MapDtosToContent(List<DocumentDto> dtos,
bool withCache = false,
bool loadProperties = true,
bool loadTemplates = true,
bool loadVariants = true)
{
var temps = new List<TempContent<Content>>();
var contentTypes = new Dictionary<int, IContentType?>();
var templateIds = new List<int>();
var content = new Content[dtos.Count];
for (var i = 0; i < dtos.Count; i++)
{
DocumentDto dto = dtos[i];
if (withCache)
{
// if the cache contains the (proper version of the) item, use it
IContent? cached =
IsolatedCache.GetCacheItem<IContent>(RepositoryCacheKeys.GetKey<IContent, int>(dto.NodeId));
if (cached != null && cached.VersionId == dto.DocumentVersionDto.ContentVersionDto.Id)
{
content[i] = (Content)cached;
continue;
}
}
// else, need to build it
// get the content type - the repository is full cache *but* still deep-clones
// whatever comes out of it, so use our own local index here to avoid this
var contentTypeId = dto.ContentDto.ContentTypeId;
if (contentTypes.TryGetValue(contentTypeId, out IContentType? contentType) == false)
{
contentTypes[contentTypeId] = contentType = _contentTypeRepository.Get(contentTypeId);
}
Content c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType);
if (loadTemplates)
{
// need templates
var templateId = dto.DocumentVersionDto.TemplateId;
if (templateId.HasValue)
{
templateIds.Add(templateId.Value);
}
if (dto.Published)
{
templateId = dto.PublishedVersionDto!.TemplateId;
if (templateId.HasValue)
{
templateIds.Add(templateId.Value);
}
}
}
// need temps, for properties, templates and variations
var versionId = dto.DocumentVersionDto.Id;
var publishedVersionId = dto.Published ? dto.PublishedVersionDto!.Id : 0;
var temp = new TempContent<Content>(dto.NodeId, versionId, publishedVersionId, contentType, c)
{
Template1Id = dto.DocumentVersionDto.TemplateId
};
if (dto.Published)
{
temp.Template2Id = dto.PublishedVersionDto!.TemplateId;
}
temps.Add(temp);
}
Dictionary<int, ITemplate>? templates = null;
if (loadTemplates)
{
// load all required templates in 1 query, and index
templates = _templateRepository.GetMany(templateIds.ToArray())?
.ToDictionary(x => x.Id, x => x);
}
IDictionary<int, PropertyCollection>? properties = null;
if (loadProperties)
{
// load all properties for all documents from database in 1 query - indexed by version id
properties = GetPropertyCollections(temps);
}
// assign templates and properties
foreach (TempContent<Content> temp in temps)
{
if (loadTemplates)
{
// set the template ID if it matches an existing template
if (temp.Template1Id.HasValue && (templates?.ContainsKey(temp.Template1Id.Value) ?? false))
{
temp.Content!.TemplateId = temp.Template1Id;
}
if (temp.Template2Id.HasValue && (templates?.ContainsKey(temp.Template2Id.Value) ?? false))
{
temp.Content!.PublishTemplateId = temp.Template2Id;
}
}
// set properties
if (loadProperties)
{
if (properties?.ContainsKey(temp.VersionId) ?? false)
{
temp.Content!.Properties = properties[temp.VersionId];
}
else
{
throw new InvalidOperationException($"No property data found for version: '{temp.VersionId}'.");
}
}
}
if (loadVariants)
{
// set variations, if varying
temps = temps.Where(x => x.ContentType?.VariesByCulture() ?? false).ToList();
if (temps.Count > 0)
{
// load all variations for all documents from database, in one query
IDictionary<int, List<ContentVariation>> contentVariations = GetContentVariations(temps);
IDictionary<int, List<DocumentVariation>> documentVariations = GetDocumentVariations(temps);
foreach (TempContent<Content> temp in temps)
{
SetVariations(temp.Content, contentVariations, documentVariations);
}
}
}
foreach (Content c in content)
{
c.ResetDirtyProperties(false); // reset dirty initial properties (U4-1946)
}
return content;
}
private IContent MapDtoToContent(DocumentDto dto)
{
IContentType? contentType = _contentTypeRepository.Get(dto.ContentDto.ContentTypeId);
Content content = ContentBaseFactory.BuildEntity(dto, contentType);
try
{
content.DisableChangeTracking();
// get template
if (dto.DocumentVersionDto.TemplateId.HasValue)
{
content.TemplateId = dto.DocumentVersionDto.TemplateId;
}
// get properties - indexed by version id
var versionId = dto.DocumentVersionDto.Id;
// TODO: shall we get published properties or not?
//var publishedVersionId = dto.Published ? dto.PublishedVersionDto.Id : 0;
var publishedVersionId = dto.PublishedVersionDto?.Id ?? 0;
var temp = new TempContent<Content>(dto.NodeId, versionId, publishedVersionId, contentType);
var ltemp = new List<TempContent<Content>> {temp};
IDictionary<int, PropertyCollection> properties = GetPropertyCollections(ltemp);
content.Properties = properties[dto.DocumentVersionDto.Id];
// set variations, if varying
if (contentType?.VariesByCulture() ?? false)
{
IDictionary<int, List<ContentVariation>> contentVariations = GetContentVariations(ltemp);
IDictionary<int, List<DocumentVariation>> documentVariations = GetDocumentVariations(ltemp);
SetVariations(content, contentVariations, documentVariations);
}
// reset dirty initial properties (U4-1946)
content.ResetDirtyProperties(false);
return content;
}
finally
{
content.EnableChangeTracking();
}
}
private void SetVariations(Content? content, IDictionary<int, List<ContentVariation>> contentVariations,
IDictionary<int, List<DocumentVariation>> documentVariations)
{
if (content is null)
{
return;
}
if (contentVariations.TryGetValue(content.VersionId, out List<ContentVariation>? contentVariation))
{
foreach (ContentVariation v in contentVariation)
{
content.SetCultureInfo(v.Culture, v.Name, v.Date);
}
}
if (content.PublishedVersionId > 0 &&
contentVariations.TryGetValue(content.PublishedVersionId, out contentVariation))
{
foreach (ContentVariation v in contentVariation)
{
content.SetPublishInfo(v.Culture, v.Name, v.Date);
}
}
if (documentVariations.TryGetValue(content.Id, out List<DocumentVariation>? documentVariation))
{
content.SetCultureEdited(documentVariation.Where(x => x.Edited).Select(x => x.Culture));
}
}
private IDictionary<int, List<ContentVariation>> GetContentVariations<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, List<ContentVariation>>();
}
IEnumerable<ContentVersionCultureVariationDto> dtos =
Database.FetchByGroups<ContentVersionCultureVariationDto, int>(versions, Constants.Sql.MaxParameterCount,
batch
=> Sql()
.Select<ContentVersionCultureVariationDto>()
.From<ContentVersionCultureVariationDto>()
.WhereIn<ContentVersionCultureVariationDto>(x => x.VersionId, batch));
var variations = new Dictionary<int, List<ContentVariation>>();
foreach (ContentVersionCultureVariationDto dto in dtos)
{
if (!variations.TryGetValue(dto.VersionId, out List<ContentVariation>? variation))
{
variations[dto.VersionId] = variation = new List<ContentVariation>();
}
variation.Add(new ContentVariation
{
Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Name = dto.Name, Date = dto.UpdateDate
});
}
return variations;
}
private IDictionary<int, List<DocumentVariation>> GetDocumentVariations<T>(List<TempContent<T>> temps)
where T : class, IContentBase
{
IEnumerable<int> ids = temps.Select(x => x.Id);
IEnumerable<DocumentCultureVariationDto> dtos = Database.FetchByGroups<DocumentCultureVariationDto, int>(ids,
Constants.Sql.MaxParameterCount, batch =>
Sql()
.Select<DocumentCultureVariationDto>()
.From<DocumentCultureVariationDto>()
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, batch));
var variations = new Dictionary<int, List<DocumentVariation>>();
foreach (DocumentCultureVariationDto dto in dtos)
{
if (!variations.TryGetValue(dto.NodeId, out List<DocumentVariation>? variation))
{
variations[dto.NodeId] = variation = new List<DocumentVariation>();
}
variation.Add(new DocumentVariation
{
Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId), Edited = dto.Edited
});
}
return variations;
}
private IEnumerable<ContentVersionCultureVariationDto> GetContentVariationDtos(IContent content, bool publishing)
{
if (content.CultureInfos is not null)
{
// create dtos for the 'current' (non-published) version, all cultures
// ReSharper disable once UseDeconstruction
foreach (ContentCultureInfos cultureInfo in content.CultureInfos)
{
yield return new ContentVersionCultureVariationDto
{
VersionId = content.VersionId,
LanguageId =
LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ??
throw new InvalidOperationException("Not a valid culture."),
Culture = cultureInfo.Culture,
Name = cultureInfo.Name,
UpdateDate =
content.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value
};
}
}
// if not publishing, we're just updating the 'current' (non-published) version,
// so there are no DTOs to create for the 'published' version which remains unchanged
if (!publishing)
{
yield break;
}
if (content.PublishCultureInfos is not null)
{
// create dtos for the 'published' version, for published cultures (those having a name)
// ReSharper disable once UseDeconstruction
foreach (ContentCultureInfos cultureInfo in content.PublishCultureInfos)
{
yield return new ContentVersionCultureVariationDto
{
VersionId = content.PublishedVersionId,
LanguageId =
LanguageRepository.GetIdByIsoCode(cultureInfo.Culture) ??
throw new InvalidOperationException("Not a valid culture."),
Culture = cultureInfo.Culture,
Name = cultureInfo.Name,
UpdateDate =
content.GetPublishDate(cultureInfo.Culture) ?? DateTime.MinValue // we *know* there is a value
};
}
}
}
private IEnumerable<DocumentCultureVariationDto> GetDocumentVariationDtos(IContent content,
HashSet<string> editedCultures)
{
IEnumerable<string>
allCultures = content.AvailableCultures.Union(content.PublishedCultures); // union = distinct
foreach (var culture in allCultures)
{
var dto = new DocumentCultureVariationDto
{
NodeId = content.Id,
LanguageId =
LanguageRepository.GetIdByIsoCode(culture) ??
throw new InvalidOperationException("Not a valid culture."),
Culture = culture,
Name = content.GetCultureName(culture) ?? content.GetPublishName(culture),
Available = content.IsCultureAvailable(culture),
Published = content.IsCulturePublished(culture),
// note: can't use IsCultureEdited at that point - hasn't been updated yet - see PersistUpdatedItem
Edited = content.IsCultureAvailable(culture) &&
(!content.IsCulturePublished(culture) ||
(editedCultures != null && editedCultures.Contains(culture)))
};
yield return dto;
}
}
private class ContentVariation
{
public string? Culture { get; set; }
public string? Name { get; set; }
public DateTime Date { get; set; }
}
private class DocumentVariation
{
public string? Culture { get; set; }
public bool Edited { get; set; }
}
#region Repository Base
protected override Guid NodeObjectTypeId => Constants.ObjectTypes.Document;
protected override IContent? PerformGet(int id)
{
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Single)
.Where<NodeDto>(x => x.NodeId == id)
.SelectTop(1);
DocumentDto? dto = Database.Fetch<DocumentDto>(sql).FirstOrDefault();
return dto == null
? null
: MapDtoToContent(dto);
}
protected override IEnumerable<IContent> PerformGetAll(params int[]? ids)
{
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Many);
if (ids?.Any() ?? false)
{
sql.WhereIn<NodeDto>(x => x.NodeId, ids);
}
return MapDtosToContent(Database.Fetch<DocumentDto>(sql));
}
protected override IEnumerable<IContent> PerformGetByQuery(IQuery<IContent> query)
{
Sql<ISqlContext> sqlClause = GetBaseQuery(QueryType.Many);
var translator = new SqlTranslator<IContent>(sqlClause, query);
Sql<ISqlContext> sql = translator.Translate();
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch<DocumentDto>(sql));
}
private void AddGetByQueryOrderBy(Sql<ISqlContext> sql) =>
sql
.OrderBy<NodeDto>(x => x.Level)
.OrderBy<NodeDto>(x => x.SortOrder);
protected override Sql<ISqlContext> GetBaseQuery(QueryType queryType) => GetBaseQuery(queryType, true);
// gets the COALESCE expression for variant/invariant name
private string VariantNameSqlExpression
=> SqlContext.VisitDto<ContentVersionCultureVariationDto, NodeDto>((ccv, node) => ccv.Name ?? node.Text, "ccv")
.Sql;
protected Sql<ISqlContext> GetBaseQuery(QueryType queryType, bool current)
{
Sql<ISqlContext> sql = SqlContext.Sql();
switch (queryType)
{
case QueryType.Count:
sql = sql.SelectCount();
break;
case QueryType.Ids:
sql = sql.Select<DocumentDto>(x => x.NodeId);
break;
case QueryType.Single:
case QueryType.Many:
// R# may flag this ambiguous and red-squiggle it, but it is not
sql = sql.Select<DocumentDto>(r =>
r.Select(documentDto => documentDto.ContentDto, r1 =>
r1.Select(contentDto => contentDto.NodeDto))
.Select(documentDto => documentDto.DocumentVersionDto, r1 =>
r1.Select(documentVersionDto => documentVersionDto.ContentVersionDto))
.Select(documentDto => documentDto.PublishedVersionDto, "pdv", r1 =>
r1.Select(documentVersionDto => documentVersionDto!.ContentVersionDto, "pcv")))
// select the variant name, coalesce to the invariant name, as "variantName"
.AndSelect(VariantNameSqlExpression + " AS variantName");
break;
}
sql
.From<DocumentDto>()
.InnerJoin<ContentDto>().On<DocumentDto, ContentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<NodeDto>().On<ContentDto, NodeDto>(left => left.NodeId, right => right.NodeId)
// inner join on mandatory edited version
.InnerJoin<ContentVersionDto>()
.On<DocumentDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id)
// left join on optional published version
.LeftJoin<ContentVersionDto>(nested =>
nested.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id && right.Published,
"pcv", "pdv"), "pcv")
.On<DocumentDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId, aliasRight: "pcv")
// TODO: should we be joining this when the query type is not single/many?
// left join on optional culture variation
//the magic "[[[ISOCODE]]]" parameter value will be replaced in ContentRepositoryBase.GetPage() by the actual ISO code
.LeftJoin<ContentVersionCultureVariationDto>(nested =>
nested.InnerJoin<LanguageDto>("lang").On<ContentVersionCultureVariationDto, LanguageDto>(
(ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccv", "lang"), "ccv")
.On<ContentVersionDto, ContentVersionCultureVariationDto>((version, ccv) => version.Id == ccv.VersionId,
aliasRight: "ccv");
sql
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId);
// this would ensure we don't get the published version - keep for reference
//sql
// .WhereAny(
// x => x.Where<ContentVersionDto, ContentVersionDto>((x1, x2) => x1.Id != x2.Id, alias2: "pcv"),
// x => x.WhereNull<ContentVersionDto>(x1 => x1.Id, "pcv")
// );
if (current)
{
sql.Where<ContentVersionDto>(x => x.Current); // always get the current version
}
return sql;
}
protected override Sql<ISqlContext> GetBaseQuery(bool isCount) =>
GetBaseQuery(isCount ? QueryType.Count : QueryType.Single);
// ah maybe not, that what's used for eg Exists in base repo
protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Node}.id = @id";
protected override IEnumerable<string> GetDeleteClauses()
{
var list = new List<string>
{
"DELETE FROM " + Constants.DatabaseSchema.Tables.ContentSchedule + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.RedirectUrl +
" WHERE contentKey IN (SELECT uniqueId FROM " + Constants.DatabaseSchema.Tables.Node +
" WHERE id = @id)",
"DELETE FROM " + Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2Node + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2NodePermission + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.UserStartNode + " WHERE startNode = @id",
"UPDATE " + Constants.DatabaseSchema.Tables.UserGroup +
" SET startContentId = NULL WHERE startContentId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE parentId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Domain + " WHERE domainRootStructureID = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Document + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.DocumentCultureVariation + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.DocumentVersion + " WHERE id IN (SELECT id FROM " +
Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)",
"DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " +
Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)",
"DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersionCultureVariation +
" WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion +
" WHERE nodeId = @id)",
"DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.AccessRule + " WHERE accessId IN (SELECT id FROM " +
Constants.DatabaseSchema.Tables.Access +
" WHERE nodeId = @id OR loginNodeId = @id OR noAccessNodeId = @id)",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Access + " WHERE nodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Access + " WHERE loginNodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Access + " WHERE noAccessNodeId = @id",
"DELETE FROM " + Constants.DatabaseSchema.Tables.Node + " WHERE id = @id"
};
return list;
}
#endregion
#region Versions
public override IEnumerable<IContent> GetAllVersions(int nodeId)
{
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Many, false)
.Where<NodeDto>(x => x.NodeId == nodeId)
.OrderByDescending<ContentVersionDto>(x => x.Current)
.AndByDescending<ContentVersionDto>(x => x.VersionDate);
return MapDtosToContent(Database.Fetch<DocumentDto>(sql), true);
}
// TODO: This method needs to return a readonly version of IContent! The content returned
// from this method does not contain all of the data required to re-persist it and if that
// is attempted some odd things will occur.
// Either we create an IContentReadOnly (which ultimately we should for vNext so we can
// differentiate between methods that return entities that can be re-persisted or not), or
// in the meantime to not break API compatibility, we can add a property to IContentBase
// (or go further and have it on IUmbracoEntity): "IsReadOnly" and if that is true we throw
// an exception if that entity is passed to a Save method.
// Ideally we return "Slim" versions of content for all sorts of methods here and in ContentService.
// Perhaps another non-breaking alternative is to have new services like IContentServiceReadOnly
// which can return IContentReadOnly.
// We have the ability with `MapDtosToContent` to reduce the amount of data looked up for a
// content item. Ideally for paged data that populates list views, these would be ultra slim
// content items, there's no reason to populate those with really anything apart from property data,
// but until we do something like the above, we can't do that since it would be breaking and unclear.
public override IEnumerable<IContent> GetAllVersionsSlim(int nodeId, int skip, int take)
{
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Many, false)
.Where<NodeDto>(x => x.NodeId == nodeId)
.OrderByDescending<ContentVersionDto>(x => x.Current)
.AndByDescending<ContentVersionDto>(x => x.VersionDate);
var pageIndex = skip / take;
return MapDtosToContent(Database.Page<DocumentDto>(pageIndex + 1, take, sql).Items, true,
// load bare minimum, need variants though since this is used to rollback with variants
false, false);
}
public override IContent? GetVersion(int versionId)
{
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Single, false)
.Where<ContentVersionDto>(x => x.Id == versionId);
DocumentDto? dto = Database.Fetch<DocumentDto>(sql).FirstOrDefault();
return dto == null ? null : MapDtoToContent(dto);
}
// deletes a specific version
public override void DeleteVersion(int versionId)
{
// TODO: test object node type?
// get the version we want to delete
SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetVersion", tsql =>
tsql.Select<ContentVersionDto>()
.AndSelect<DocumentVersionDto>()
.From<ContentVersionDto>()
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((c, d) => c.Id == d.Id)
.Where<ContentVersionDto>(x => x.Id == SqlTemplate.Arg<int>("versionId"))
);
DocumentVersionDto? versionDto =
Database.Fetch<DocumentVersionDto>(template.Sql(new {versionId})).FirstOrDefault();
// nothing to delete
if (versionDto == null)
{
return;
}
// don't delete the current or published version
if (versionDto.ContentVersionDto.Current)
{
throw new InvalidOperationException("Cannot delete the current version.");
}
if (versionDto.Published)
{
throw new InvalidOperationException("Cannot delete the published version.");
}
PerformDeleteVersion(versionDto.ContentVersionDto.NodeId, versionId);
}
// deletes all versions of an entity, older than a date.
public override 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("Umbraco.Core.DocumentRepository.GetVersions", tsql =>
tsql.Select<ContentVersionDto>()
.From<ContentVersionDto>()
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((c, d) => c.Id == d.Id)
.Where<ContentVersionDto>(x =>
x.NodeId == SqlTemplate.Arg<int>("nodeId") && !x.Current &&
x.VersionDate < SqlTemplate.Arg<DateTime>("versionDate"))
.Where<DocumentVersionDto>(x => !x.Published)
);
List<ContentVersionDto>? versionDtos =
Database.Fetch<ContentVersionDto>(template.Sql(new {nodeId, versionDate}));
foreach (ContentVersionDto? versionDto in versionDtos)
{
PerformDeleteVersion(versionDto.NodeId, versionDto.Id);
}
}
protected override void PerformDeleteVersion(int id, int versionId)
{
Database.Delete<PropertyDataDto>("WHERE versionId = @versionId", new {versionId});
Database.Delete<ContentVersionCultureVariationDto>("WHERE versionId = @versionId", new {versionId});
Database.Delete<DocumentVersionDto>("WHERE id = @versionId", new {versionId});
Database.Delete<ContentVersionDto>("WHERE id = @versionId", new {versionId});
}
#endregion
#region Persist
protected override void PersistNewItem(IContent entity)
{
entity.AddingEntity();
var publishing = entity.PublishedState == PublishedState.Publishing;
// ensure that the default template is assigned
if (entity.TemplateId.HasValue == false)
{
entity.TemplateId = entity.ContentType.DefaultTemplate?.Id;
}
// sanitize names
SanitizeNames(entity, publishing);
// ensure that strings don't contain characters that are invalid in xml
// TODO: do we really want to keep doing this here?
entity.SanitizeEntityPropertiesForXmlStorage();
// create the dto
DocumentDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId);
// derive path and level from parent
NodeDto parent = GetParentNodeDto(entity.ParentId);
var level = parent.Level + 1;
var calculateSortOrder = (entity is { HasIdentity: false, SortOrder: 0 } && entity.IsPropertyDirty(nameof(entity.SortOrder)) is false) // SortOrder was not updated from it's default value
|| SortorderExists(entity.ParentId, entity.SortOrder);
// if the sortorder of the entity already exists get a new one, else use the sortOrder of the entity
var sortOrder = calculateSortOrder ? GetNewChildSortOrder(entity.ParentId, 0) : entity.SortOrder;
// persist the node dto
NodeDto nodeDto = dto.ContentDto.NodeDto;
nodeDto.Path = parent.Path;
nodeDto.Level = Convert.ToInt16(level);
nodeDto.SortOrder = sortOrder;
// see if there's a reserved identifier for this unique id
// and then either update or insert the node dto
var id = GetReservedId(nodeDto.UniqueId);
if (id > 0)
{
nodeDto.NodeId = id;
}
else
{
Database.Insert(nodeDto);
}
nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId);
nodeDto.ValidatePathWithException();
Database.Update(nodeDto);
// update entity
entity.Id = nodeDto.NodeId;
entity.Path = nodeDto.Path;
entity.SortOrder = sortOrder;
entity.Level = level;
// persist the content dto
ContentDto contentDto = dto.ContentDto;
contentDto.NodeId = nodeDto.NodeId;
Database.Insert(contentDto);
// persist the content version dto
ContentVersionDto contentVersionDto = dto.DocumentVersionDto.ContentVersionDto;
contentVersionDto.NodeId = nodeDto.NodeId;
contentVersionDto.Current = !publishing;
Database.Insert(contentVersionDto);
entity.VersionId = contentVersionDto.Id;
// persist the document version dto
DocumentVersionDto documentVersionDto = dto.DocumentVersionDto;
documentVersionDto.Id = entity.VersionId;
if (publishing)
{
documentVersionDto.Published = true;
}
Database.Insert(documentVersionDto);
// and again in case we're publishing immediately
if (publishing)
{
entity.PublishedVersionId = entity.VersionId;
contentVersionDto.Id = 0;
contentVersionDto.Current = true;
contentVersionDto.Text = entity.Name;
Database.Insert(contentVersionDto);
entity.VersionId = contentVersionDto.Id;
documentVersionDto.Id = entity.VersionId;
documentVersionDto.Published = false;
Database.Insert(documentVersionDto);
}
// persist the property data
IEnumerable<PropertyDataDto> propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations,
entity.VersionId, entity.PublishedVersionId, entity.Properties, LanguageRepository, out var edited,
out HashSet<string>? editedCultures);
foreach (PropertyDataDto propertyDataDto in propertyDataDtos)
{
Database.Insert(propertyDataDto);
}
// if !publishing, we may have a new name != current publish name,
// also impacts 'edited'
if (!publishing && entity.PublishName != entity.Name)
{
edited = true;
}
// persist the document dto
// at that point, when publishing, the entity still has its old Published value
// so we need to explicitly update the dto to persist the correct value
if (entity.PublishedState == PublishedState.Publishing)
{
dto.Published = true;
}
dto.NodeId = nodeDto.NodeId;
entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited
Database.Insert(dto);
// persist the variations
if (entity.ContentType.VariesByCulture())
{
// names also impact 'edited'
// ReSharper disable once UseDeconstruction
foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!)
{
if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture))
{
(editedCultures ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo.Culture);
}
}
// refresh content
entity.SetCultureEdited(editedCultures!);
// bump dates to align cultures to version
entity.AdjustDates(contentVersionDto.VersionDate, publishing);
// insert content variations
Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing));
// insert document variations
Database.BulkInsertRecords(GetDocumentVariationDtos(entity, editedCultures!));
}
// trigger here, before we reset Published etc
OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages()));
// flip the entity's published property
// this also flips its published state
// note: what depends on variations (eg PublishNames) is managed directly by the content
if (entity.PublishedState == PublishedState.Publishing)
{
entity.Published = true;
entity.PublishTemplateId = entity.TemplateId;
entity.PublisherId = entity.WriterId;
entity.PublishName = entity.Name;
entity.PublishDate = entity.UpdateDate;
SetEntityTags(entity, _tagRepository, _serializer);
}
else if (entity.PublishedState == PublishedState.Unpublishing)
{
entity.Published = false;
entity.PublishTemplateId = null;
entity.PublisherId = null;
entity.PublishName = null;
entity.PublishDate = null;
ClearEntityTags(entity, _tagRepository);
}
PersistRelations(entity);
entity.ResetDirtyProperties();
// troubleshooting
//if (Database.ExecuteScalar<int>($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.DocumentVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.DocumentVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1)
//{
// Debugger.Break();
// throw new Exception("oops");
//}
//if (Database.ExecuteScalar<int>($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.DocumentVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.DocumentVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1)
//{
// Debugger.Break();
// throw new Exception("oops");
//}
}
protected override void PersistUpdatedItem(IContent entity)
{
var isEntityDirty = entity.IsDirty();
var editedSnapshot = entity.Edited;
// check if we need to make any database changes at all
if ((entity.PublishedState == PublishedState.Published || entity.PublishedState == PublishedState.Unpublished)
&& !isEntityDirty && !entity.IsAnyUserPropertyDirty())
{
return; // no change to save, do nothing, don't even update dates
}
// whatever we do, we must check that we are saving the current version
ContentVersionDto? version = Database.Fetch<ContentVersionDto>(SqlContext.Sql().Select<ContentVersionDto>()
.From<ContentVersionDto>().Where<ContentVersionDto>(x => x.Id == entity.VersionId)).FirstOrDefault();
if (version == null || !version.Current)
{
throw new InvalidOperationException("Cannot save a non-current version.");
}
// update
entity.UpdatingEntity();
// Check if this entity is being moved as a descendant as part of a bulk moving operations.
// In this case we can bypass a lot of the below operations which will make this whole operation go much faster.
// When moving we don't need to create new versions, etc... because we cannot roll this operation back anyways.
var isMoving = entity.IsMoving();
// TODO: I'm sure we can also detect a "Copy" (of a descendant) operation and probably perform similar checks below.
// There is probably more stuff that would be required for copying but I'm sure not all of this logic would be, we could more than likely boost
// copy performance by 95% just like we did for Move
var publishing = entity.PublishedState == PublishedState.Publishing;
if (!isMoving)
{
// check if we need to create a new version
if (publishing && entity.PublishedVersionId > 0)
{
// published version is not published anymore
Database.Execute(Sql().Update<DocumentVersionDto>(u => u.Set(x => x.Published, false))
.Where<DocumentVersionDto>(x => x.Id == entity.PublishedVersionId));
}
// sanitize names
SanitizeNames(entity, publishing);
// ensure that strings don't contain characters that are invalid in xml
// TODO: do we really want to keep doing this here?
entity.SanitizeEntityPropertiesForXmlStorage();
// if parent has changed, get path, level and sort order
if (entity.IsPropertyDirty("ParentId"))
{
NodeDto parent = GetParentNodeDto(entity.ParentId);
entity.Path = string.Concat(parent.Path, ",", entity.Id);
entity.Level = parent.Level + 1;
entity.SortOrder = GetNewChildSortOrder(entity.ParentId, 0);
}
}
// create the dto
DocumentDto dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId);
// update the node dto
NodeDto nodeDto = dto.ContentDto.NodeDto;
nodeDto.ValidatePathWithException();
Database.Update(nodeDto);
if (!isMoving)
{
// update the content dto
Database.Update(dto.ContentDto);
// update the content & document version dtos
ContentVersionDto contentVersionDto = dto.DocumentVersionDto.ContentVersionDto;
DocumentVersionDto documentVersionDto = dto.DocumentVersionDto;
if (publishing)
{
documentVersionDto.Published = true; // now published
contentVersionDto.Current = false; // no more current
}
// Ensure existing version retains current preventCleanup flag (both saving and publishing).
contentVersionDto.PreventCleanup = version.PreventCleanup;
Database.Update(contentVersionDto);
Database.Update(documentVersionDto);
// and, if publishing, insert new content & document version dtos
if (publishing)
{
entity.PublishedVersionId = entity.VersionId;
contentVersionDto.Id = 0; // want a new id
contentVersionDto.Current = true; // current version
contentVersionDto.Text = entity.Name;
contentVersionDto.PreventCleanup = false; // new draft version disregards prevent cleanup flag
Database.Insert(contentVersionDto);
entity.VersionId = documentVersionDto.Id = contentVersionDto.Id; // get the new id
documentVersionDto.Published = false; // non-published version
Database.Insert(documentVersionDto);
}
// replace the property data (rather than updating)
// only need to delete for the version that existed, the new version (if any) has no property data yet
var versionToDelete = publishing ? entity.PublishedVersionId : entity.VersionId;
// insert property data
ReplacePropertyValues(entity, versionToDelete, publishing ? entity.PublishedVersionId : 0, out var edited,
out HashSet<string>? editedCultures);
// if !publishing, we may have a new name != current publish name,
// also impacts 'edited'
if (!publishing && entity.PublishName != entity.Name)
{
edited = true;
}
// To establish the new value of "edited" we compare all properties publishedValue to editedValue and look
// for differences.
//
// If we SaveAndPublish but the publish fails (e.g. already scheduled for release)
// we have lost the publishedValue on IContent (in memory vs database) so we cannot correctly make that comparison.
//
// This is a slight change to behaviour, historically a publish, followed by change & save, followed by undo change & save
// would change edited back to false.
if (!publishing && editedSnapshot)
{
edited = true;
}
if (entity.ContentType.VariesByCulture())
{
// names also impact 'edited'
// ReSharper disable once UseDeconstruction
foreach (ContentCultureInfos cultureInfo in entity.CultureInfos!)
{
if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture))
{
edited = true;
(editedCultures ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase)).Add(cultureInfo
.Culture);
// TODO: change tracking
// at the moment, we don't do any dirty tracking on property values, so we don't know whether the
// culture has just been edited or not, so we don't update its update date - that date only changes
// when the name is set, and it all works because the controller does it - but, if someone uses a
// service to change a property value and save (without setting name), the update date does not change.
}
}
// refresh content
entity.SetCultureEdited(editedCultures!);
// bump dates to align cultures to version
entity.AdjustDates(contentVersionDto.VersionDate, publishing);
// replace the content version variations (rather than updating)
// only need to delete for the version that existed, the new version (if any) has no property data yet
Sql<ISqlContext> deleteContentVariations = Sql().Delete<ContentVersionCultureVariationDto>()
.Where<ContentVersionCultureVariationDto>(x => x.VersionId == versionToDelete);
Database.Execute(deleteContentVariations);
// replace the document version variations (rather than updating)
Sql<ISqlContext> deleteDocumentVariations = Sql().Delete<DocumentCultureVariationDto>()
.Where<DocumentCultureVariationDto>(x => x.NodeId == entity.Id);
Database.Execute(deleteDocumentVariations);
// TODO: NPoco InsertBulk issue?
// we should use the native NPoco InsertBulk here but it causes problems (not sure exactly all scenarios)
// but by using SQL Server and updating a variants name will cause: Unable to cast object of type
// 'Umbraco.Core.Persistence.FaultHandling.RetryDbConnection' to type 'System.Data.SqlClient.SqlConnection'.
// (same in PersistNewItem above)
// insert content variations
Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing));
// insert document variations
Database.BulkInsertRecords(GetDocumentVariationDtos(entity, editedCultures!));
}
// update the document dto
// at that point, when un/publishing, the entity still has its old Published value
// so we need to explicitly update the dto to persist the correct value
if (entity.PublishedState == PublishedState.Publishing)
{
dto.Published = true;
}
else if (entity.PublishedState == PublishedState.Unpublishing)
{
dto.Published = false;
}
entity.Edited = dto.Edited = !dto.Published || edited; // if not published, always edited
Database.Update(dto);
// if entity is publishing, update tags, else leave tags there
// means that implicitly unpublished, or trashed, entities *still* have tags in db
if (entity.PublishedState == PublishedState.Publishing)
{
SetEntityTags(entity, _tagRepository, _serializer);
}
}
// trigger here, before we reset Published etc
OnUowRefreshedEntity(new ContentRefreshNotification(entity, new EventMessages()));
if (!isMoving)
{
// flip the entity's published property
// this also flips its published state
if (entity.PublishedState == PublishedState.Publishing)
{
entity.Published = true;
entity.PublishTemplateId = entity.TemplateId;
entity.PublisherId = entity.WriterId;
entity.PublishName = entity.Name;
entity.PublishDate = entity.UpdateDate;
SetEntityTags(entity, _tagRepository, _serializer);
}
else if (entity.PublishedState == PublishedState.Unpublishing)
{
entity.Published = false;
entity.PublishTemplateId = null;
entity.PublisherId = null;
entity.PublishName = null;
entity.PublishDate = null;
ClearEntityTags(entity, _tagRepository);
}
PersistRelations(entity);
// TODO: note re. tags: explicitly unpublished entities have cleared tags, but masked or trashed entities *still* have tags in the db - so what?
}
entity.ResetDirtyProperties();
// troubleshooting
//if (Database.ExecuteScalar<int>($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.DocumentVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.DocumentVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE published=1 AND nodeId=" + content.Id) > 1)
//{
// Debugger.Break();
// throw new Exception("oops");
//}
//if (Database.ExecuteScalar<int>($"SELECT COUNT(*) FROM {Constants.DatabaseSchema.Tables.DocumentVersion} JOIN {Constants.DatabaseSchema.Tables.ContentVersion} ON {Constants.DatabaseSchema.Tables.DocumentVersion}.id={Constants.DatabaseSchema.Tables.ContentVersion}.id WHERE [current]=1 AND nodeId=" + content.Id) > 1)
//{
// Debugger.Break();
// throw new Exception("oops");
//}
}
/// <inheritdoc />
public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule)
{
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
if (contentSchedule == null)
{
throw new ArgumentNullException(nameof(contentSchedule));
}
var schedules = ContentBaseFactory.BuildScheduleDto(content, contentSchedule, LanguageRepository).ToList();
//remove any that no longer exist
IEnumerable<Guid> ids = schedules.Where(x => x.Model.Id != Guid.Empty).Select(x => x.Model.Id).Distinct();
Database.Execute(Sql()
.Delete<ContentScheduleDto>()
.Where<ContentScheduleDto>(x => x.NodeId == content.Id)
.WhereNotIn<ContentScheduleDto>(x => x.Id, ids));
//add/update the rest
foreach ((ContentSchedule Model, ContentScheduleDto Dto) schedule in schedules)
{
if (schedule.Model.Id == Guid.Empty)
{
schedule.Model.Id = schedule.Dto.Id = Guid.NewGuid();
Database.Insert(schedule.Dto);
}
else
{
Database.Update(schedule.Dto);
}
}
}
protected override void PersistDeletedItem(IContent entity)
{
// Raise event first else potential FK issues
OnUowRemovingEntity(entity);
//We need to clear out all access rules but we need to do this in a manual way since
// nothing in that table is joined to a content id
Sql<ISqlContext> subQuery = SqlContext.Sql()
.Select<AccessRuleDto>(x => x.AccessId)
.From<AccessRuleDto>()
.InnerJoin<AccessDto>()
.On<AccessRuleDto, AccessDto>(left => left.AccessId, right => right.Id)
.Where<AccessDto>(dto => dto.NodeId == entity.Id);
Database.Execute(SqlContext.SqlSyntax.GetDeleteSubquery("umbracoAccessRule", "accessId", subQuery));
//now let the normal delete clauses take care of everything else
base.PersistDeletedItem(entity);
}
#endregion
#region Content Repository
public int CountPublished(string? contentTypeAlias = null)
{
Sql<ISqlContext> sql = SqlContext.Sql();
if (contentTypeAlias.IsNullOrWhiteSpace())
{
sql.SelectCount()
.From<NodeDto>()
.InnerJoin<DocumentDto>()
.On<NodeDto, DocumentDto>(left => left.NodeId, right => right.NodeId)
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false)
.Where<DocumentDto>(x => x.Published);
}
else
{
sql.SelectCount()
.From<NodeDto>()
.InnerJoin<ContentDto>()
.On<NodeDto, ContentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<DocumentDto>()
.On<NodeDto, DocumentDto>(left => left.NodeId, right => right.NodeId)
.InnerJoin<ContentTypeDto>()
.On<ContentTypeDto, ContentDto>(left => left.NodeId, right => right.ContentTypeId)
.Where<NodeDto>(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false)
.Where<ContentTypeDto>(x => x.Alias == contentTypeAlias)
.Where<DocumentDto>(x => x.Published);
}
return Database.ExecuteScalar<int>(sql);
}
public void ReplaceContentPermissions(EntityPermissionSet permissionSet) =>
PermissionRepository.ReplaceEntityPermissions(permissionSet);
/// <summary>
/// Assigns a single permission to the current content item for the specified group ids
/// </summary>
/// <param name="entity"></param>
/// <param name="permission"></param>
/// <param name="groupIds"></param>
public void AssignEntityPermission(IContent entity, char permission, IEnumerable<int> groupIds) =>
PermissionRepository.AssignEntityPermission(entity, permission, groupIds);
public EntityPermissionCollection GetPermissionsForEntity(int entityId) =>
PermissionRepository.GetPermissionsForEntity(entityId);
/// <summary>
/// Used to add/update a permission for a content item
/// </summary>
/// <param name="permission"></param>
public void AddOrUpdatePermissions(ContentPermissionSet permission) => PermissionRepository.Save(permission);
/// <inheritdoc />
public override IEnumerable<IContent> GetPage(IQuery<IContent>? query,
long pageIndex, int pageSize, out long totalRecords,
IQuery<IContent>? filter, Ordering? ordering)
{
Sql<ISqlContext>? filterSql = null;
// if we have a filter, map its clauses to an Sql statement
if (filter != null)
{
// if the clause works on "name", we need to swap the field and use the variantName instead,
// so that querying also works on variant content (for instance when searching a listview).
// figure out how the "name" field is going to look like - so we can look for it
var nameField = SqlContext.VisitModelField<IContent>(x => x.Name);
filterSql = Sql();
foreach (Tuple<string, object[]> filterClause in filter.GetWhereClauses())
{
var clauseSql = filterClause.Item1;
var clauseArgs = filterClause.Item2;
// replace the name field
// we cannot reference an aliased field in a WHERE clause, so have to repeat the expression here
clauseSql = clauseSql.Replace(nameField, VariantNameSqlExpression);
// append the clause
filterSql.Append($"AND ({clauseSql})", clauseArgs);
}
}
return GetPage<DocumentDto>(query, pageIndex, pageSize, out totalRecords,
x => MapDtosToContent(x),
filterSql,
ordering);
}
public bool IsPathPublished(IContent? content)
{
// fail fast
if (content?.Path.StartsWith("-1,-20,") ?? false)
{
return false;
}
// succeed fast
if (content?.ParentId == -1)
{
return content.Published;
}
IEnumerable<int>? ids = content?.Path.Split(Constants.CharArrays.Comma).Skip(1)
.Select(s => int.Parse(s, CultureInfo.InvariantCulture));
Sql<ISqlContext> sql = SqlContext.Sql()
.SelectCount<NodeDto>(x => x.NodeId)
.From<NodeDto>()
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId && d.Published)
.WhereIn<NodeDto>(x => x.NodeId, ids);
var count = Database.ExecuteScalar<int>(sql);
return count == content?.Level;
}
#endregion
#region Recycle Bin
public override int RecycleBinId => Constants.System.RecycleBinContent;
public bool RecycleBinSmells()
{
IAppPolicyCache cache = _appCaches.RuntimeCache;
var cacheKey = CacheKeys.ContentRecycleBinCacheKey;
// always cache either true or false
return cache.GetCacheItem(cacheKey, () => CountChildren(RecycleBinId) > 0);
}
#endregion
#region Read Repository implementation for Guid keys
public IContent? Get(Guid id) => _contentByGuidReadRepository.Get(id);
IEnumerable<IContent> IReadRepository<Guid, IContent>.GetMany(params Guid[]? ids) =>
_contentByGuidReadRepository.GetMany(ids);
public bool Exists(Guid id) => _contentByGuidReadRepository.Exists(id);
// reading repository purely for looking up by GUID
// TODO: ugly and to fix we need to decouple the IRepositoryQueryable -> IRepository -> IReadRepository which should all be separate things!
// This sub-repository pattern is super old and totally unecessary anymore, caching can be handled in much nicer ways without this
private class ContentByGuidReadRepository : EntityRepositoryBase<Guid, IContent>
{
private readonly DocumentRepository _outerRepo;
public ContentByGuidReadRepository(DocumentRepository outerRepo, IScopeAccessor scopeAccessor, AppCaches cache,
ILogger<ContentByGuidReadRepository> logger)
: base(scopeAccessor, cache, logger) =>
_outerRepo = outerRepo;
protected override IContent? PerformGet(Guid id)
{
Sql<ISqlContext> sql = _outerRepo.GetBaseQuery(QueryType.Single)
.Where<NodeDto>(x => x.UniqueId == id);
DocumentDto? dto = Database.Fetch<DocumentDto>(sql.SelectTop(1)).FirstOrDefault();
if (dto == null)
{
return null;
}
IContent content = _outerRepo.MapDtoToContent(dto);
return content;
}
protected override IEnumerable<IContent> PerformGetAll(params Guid[]? ids)
{
Sql<ISqlContext> sql = _outerRepo.GetBaseQuery(QueryType.Many);
if (ids?.Length > 0)
{
sql.WhereIn<NodeDto>(x => x.UniqueId, ids);
}
return _outerRepo.MapDtosToContent(Database.Fetch<DocumentDto>(sql));
}
protected override IEnumerable<IContent> PerformGetByQuery(IQuery<IContent> query) =>
throw new InvalidOperationException("This method won't be implemented.");
protected override IEnumerable<string> GetDeleteClauses() =>
throw new InvalidOperationException("This method won't be implemented.");
protected override void PersistNewItem(IContent entity) =>
throw new InvalidOperationException("This method won't be implemented.");
protected override void PersistUpdatedItem(IContent entity) =>
throw new InvalidOperationException("This method won't be implemented.");
protected override Sql<ISqlContext> GetBaseQuery(bool isCount) =>
throw new InvalidOperationException("This method won't be implemented.");
protected override string GetBaseWhereClause() =>
throw new InvalidOperationException("This method won't be implemented.");
}
#endregion
#region Schedule
/// <inheritdoc />
public void ClearSchedule(DateTime date)
{
Sql<ISqlContext> sql = Sql().Delete<ContentScheduleDto>().Where<ContentScheduleDto>(x => x.Date <= date);
Database.Execute(sql);
}
/// <inheritdoc />
public void ClearSchedule(DateTime date, ContentScheduleAction action)
{
var a = action.ToString();
Sql<ISqlContext> sql = Sql().Delete<ContentScheduleDto>()
.Where<ContentScheduleDto>(x => x.Date <= date && x.Action == a);
Database.Execute(sql);
}
private Sql GetSqlForHasScheduling(ContentScheduleAction action, DateTime date)
{
SqlTemplate template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetSqlForHasScheduling",
tsql => tsql
.SelectCount()
.From<ContentScheduleDto>()
.Where<ContentScheduleDto>(x =>
x.Action == SqlTemplate.Arg<string>("action") && x.Date <= SqlTemplate.Arg<DateTime>("date")));
Sql<ISqlContext> sql = template.Sql(action.ToString(), date);
return sql;
}
public bool HasContentForExpiration(DateTime date)
{
Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Expire, date);
return Database.ExecuteScalar<int>(sql) > 0;
}
public bool HasContentForRelease(DateTime date)
{
Sql sql = GetSqlForHasScheduling(ContentScheduleAction.Release, date);
return Database.ExecuteScalar<int>(sql) > 0;
}
/// <inheritdoc />
public IEnumerable<IContent> GetContentForRelease(DateTime date)
{
var action = ContentScheduleAction.Release.ToString();
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Many)
.WhereIn<NodeDto>(x => x.NodeId, Sql()
.Select<ContentScheduleDto>(x => x.NodeId)
.From<ContentScheduleDto>()
.Where<ContentScheduleDto>(x => x.Action == action && x.Date <= date));
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch<DocumentDto>(sql));
}
/// <inheritdoc />
public IEnumerable<IContent> GetContentForExpiration(DateTime date)
{
var action = ContentScheduleAction.Expire.ToString();
Sql<ISqlContext> sql = GetBaseQuery(QueryType.Many)
.WhereIn<NodeDto>(x => x.NodeId, Sql()
.Select<ContentScheduleDto>(x => x.NodeId)
.From<ContentScheduleDto>()
.Where<ContentScheduleDto>(x => x.Action == action && x.Date <= date));
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch<DocumentDto>(sql));
}
#endregion
#region Utilities
private void SanitizeNames(IContent content, bool publishing)
{
// a content item *must* have an invariant name, and invariant published name
// else we just cannot write the invariant rows (node, content version...) to the database
// ensure that we have an invariant name
// invariant content = must be there already, else throw
// variant content = update with default culture or anything really
EnsureInvariantNameExists(content);
// ensure that invariant name is unique
EnsureInvariantNameIsUnique(content);
// and finally,
// ensure that each culture has a unique node name
// no published name = not published
// else, it needs to be unique
EnsureVariantNamesAreUnique(content, publishing);
}
private void EnsureInvariantNameExists(IContent content)
{
if (content.ContentType.VariesByCulture())
{
// content varies by culture
// then it must have at least a variant name, else it makes no sense
if (content.CultureInfos?.Count == 0)
{
throw new InvalidOperationException("Cannot save content with an empty name.");
}
// and then, we need to set the invariant name implicitly,
// using the default culture if it has a name, otherwise anything we can
var defaultCulture = LanguageRepository.GetDefaultIsoCode();
content.Name = defaultCulture != null &&
(content.CultureInfos?.TryGetValue(defaultCulture, out ContentCultureInfos cultureName) ??
false)
? cultureName.Name!
: content.CultureInfos![0].Name!;
}
else
{
// content is invariant, and invariant content must have an explicit invariant name
if (string.IsNullOrWhiteSpace(content.Name))
{
throw new InvalidOperationException("Cannot save content with an empty name.");
}
}
}
private void EnsureInvariantNameIsUnique(IContent content) =>
content.Name = EnsureUniqueNodeName(content.ParentId, content.Name, content.Id);
protected override string? EnsureUniqueNodeName(int parentId, string? nodeName, int id = 0) =>
EnsureUniqueNaming == false ? nodeName : base.EnsureUniqueNodeName(parentId, nodeName, id);
private SqlTemplate SqlEnsureVariantNamesAreUnique => SqlContext.Templates.Get(
"Umbraco.Core.DomainRepository.EnsureVariantNamesAreUnique", tsql => tsql
.Select<ContentVersionCultureVariationDto>(x => x.Id, x => x.Name, x => x.LanguageId)
.From<ContentVersionCultureVariationDto>()
.InnerJoin<ContentVersionDto>()
.On<ContentVersionDto, ContentVersionCultureVariationDto>(x => x.Id, x => x.VersionId)
.InnerJoin<NodeDto>().On<NodeDto, ContentVersionDto>(x => x.NodeId, x => x.NodeId)
.Where<ContentVersionDto>(x => x.Current == SqlTemplate.Arg<bool>("current"))
.Where<NodeDto>(x => x.NodeObjectType == SqlTemplate.Arg<Guid>("nodeObjectType") &&
x.ParentId == SqlTemplate.Arg<int>("parentId") &&
x.NodeId != SqlTemplate.Arg<int>("id"))
.OrderBy<ContentVersionCultureVariationDto>(x => x.LanguageId));
private void EnsureVariantNamesAreUnique(IContent content, bool publishing)
{
if (!EnsureUniqueNaming || !content.ContentType.VariesByCulture() || content.CultureInfos?.Count == 0)
{
return;
}
// get names per culture, at same level (ie all siblings)
Sql<ISqlContext> sql = SqlEnsureVariantNamesAreUnique.Sql(true, NodeObjectTypeId, content.ParentId, content.Id);
var names = Database.Fetch<CultureNodeName>(sql)
.GroupBy(x => x.LanguageId)
.ToDictionary(x => x.Key, x => x);
if (names.Count == 0)
{
return;
}
// note: the code below means we are going to unique-ify every culture names, regardless
// of whether the name has changed (ie the culture has been updated) - some saving culture
// fr-FR could cause culture en-UK name to change - not sure that is clean
if (content.CultureInfos is null)
{
return;
}
foreach (ContentCultureInfos cultureInfo in content.CultureInfos)
{
var langId = LanguageRepository.GetIdByIsoCode(cultureInfo.Culture);
if (!langId.HasValue)
{
continue;
}
if (!names.TryGetValue(langId.Value, out IGrouping<int, CultureNodeName>? cultureNames))
{
continue;
}
// get a unique name
IEnumerable<SimilarNodeName> otherNames =
cultureNames.Select(x => new SimilarNodeName {Id = x.Id, Name = x.Name});
var uniqueName = SimilarNodeName.GetUniqueName(otherNames, 0, cultureInfo.Name);
if (uniqueName == content.GetCultureName(cultureInfo.Culture))
{
continue;
}
// update the name, and the publish name if published
content.SetCultureName(uniqueName, cultureInfo.Culture);
if (publishing && (content.PublishCultureInfos?.ContainsKey(cultureInfo.Culture) ?? false))
{
content.SetPublishInfo(cultureInfo.Culture, uniqueName,
DateTime.Now); //TODO: This is weird, this call will have already been made in the SetCultureName
}
}
}
// ReSharper disable once ClassNeverInstantiated.Local
private class CultureNodeName
{
public int Id { get; set; }
public string? Name { get; set; }
public int LanguageId { get; set; }
}
#endregion
}