using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
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.Scoping;
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.Extensions;
namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
{
///
/// Represents a repository for doing CRUD operations for .
///
public class DocumentRepository : ContentRepositoryBase, IDocumentRepository
{
private readonly IContentTypeRepository _contentTypeRepository;
private readonly ITemplateRepository _templateRepository;
private readonly ITagRepository _tagRepository;
private readonly IJsonSerializer _serializer;
private readonly AppCaches _appCaches;
private readonly ILoggerFactory _loggerFactory;
private PermissionRepository _permissionRepository;
private readonly ContentByGuidReadRepository _contentByGuidReadRepository;
private readonly IScopeAccessor _scopeAccessor;
///
/// Constructor
///
///
///
///
///
///
///
///
///
///
/// 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
///
public DocumentRepository(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
ILogger 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());
}
protected override DocumentRepository This => this;
///
/// Default is to always ensure all documents have unique names
///
protected virtual bool EnsureUniqueNaming { get; } = true;
// note: is ok to 'new' the repo here as it's a sub-repo really
private PermissionRepository PermissionRepository => _permissionRepository
?? (_permissionRepository = new PermissionRepository(_scopeAccessor, _appCaches, _loggerFactory.CreateLogger>()));
#region Repository Base
protected override Guid NodeObjectTypeId => Cms.Core.Constants.ObjectTypes.Document;
protected override IContent PerformGet(int id)
{
var sql = GetBaseQuery(QueryType.Single)
.Where(x => x.NodeId == id)
.SelectTop(1);
var dto = Database.Fetch(sql).FirstOrDefault();
return dto == null
? null
: MapDtoToContent(dto);
}
protected override IEnumerable PerformGetAll(params int[] ids)
{
var sql = GetBaseQuery(QueryType.Many);
if (ids.Any())
sql.WhereIn(x => x.NodeId, ids);
return MapDtosToContent(Database.Fetch(sql));
}
protected override IEnumerable PerformGetByQuery(IQuery query)
{
var sqlClause = GetBaseQuery(QueryType.Many);
var translator = new SqlTranslator(sqlClause, query);
var sql = translator.Translate();
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch(sql));
}
private void AddGetByQueryOrderBy(Sql sql)
{
sql
.OrderBy(x => x.Level)
.OrderBy(x => x.SortOrder);
}
protected override Sql GetBaseQuery(QueryType queryType)
{
return GetBaseQuery(queryType, true);
}
// gets the COALESCE expression for variant/invariant name
private string VariantNameSqlExpression
=> SqlContext.VisitDto((ccv, node) => ccv.Name ?? node.Text, "ccv").Sql;
protected Sql GetBaseQuery(QueryType queryType, bool current)
{
var sql = SqlContext.Sql();
switch (queryType)
{
case QueryType.Count:
sql = sql.SelectCount();
break;
case QueryType.Ids:
sql = sql.Select(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(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()
.InnerJoin().On(left => left.NodeId, right => right.NodeId)
.InnerJoin().On(left => left.NodeId, right => right.NodeId)
// inner join on mandatory edited version
.InnerJoin()
.On((left, right) => left.NodeId == right.NodeId)
.InnerJoin()
.On((left, right) => left.Id == right.Id)
// left join on optional published version
.LeftJoin(nested =>
nested.InnerJoin("pdv")
.On((left, right) => left.Id == right.Id && right.Published, "pcv", "pdv"), "pcv")
.On((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(nested =>
nested.InnerJoin("lang").On((ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccv", "lang"), "ccv")
.On((version, ccv) => version.Id == ccv.VersionId, aliasRight: "ccv");
sql
.Where(x => x.NodeObjectType == NodeObjectTypeId);
// this would ensure we don't get the published version - keep for reference
//sql
// .WhereAny(
// x => x.Where((x1, x2) => x1.Id != x2.Id, alias2: "pcv"),
// x => x.WhereNull(x1 => x1.Id, "pcv")
// );
if (current)
sql.Where(x => x.Current); // always get the current version
return sql;
}
protected override Sql GetBaseQuery(bool isCount)
{
return GetBaseQuery(isCount ? QueryType.Count : QueryType.Single);
}
// ah maybe not, that what's used for eg Exists in base repo
protected override string GetBaseWhereClause()
{
return $"{Cms.Core.Constants.DatabaseSchema.Tables.Node}.id = @id";
}
protected override IEnumerable GetDeleteClauses()
{
var list = new List
{
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentSchedule + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.RedirectUrl + " WHERE contentKey IN (SELECT uniqueId FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Node + " WHERE id = @id)",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.UserGroup2NodePermission + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.UserStartNode + " WHERE startNode = @id",
"UPDATE " + Cms.Core.Constants.DatabaseSchema.Tables.UserGroup + " SET startContentId = NULL WHERE startContentId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Relation + " WHERE parentId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Domain + " WHERE domainRootStructureID = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Document + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.DocumentCultureVariation + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.DocumentVersion + " WHERE id IN (SELECT id FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentVersionCultureVariation + " WHERE versionId IN (SELECT id FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.AccessRule + " WHERE accessId IN (SELECT id FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Access + " WHERE nodeId = @id OR loginNodeId = @id OR noAccessNodeId = @id)",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Access + " WHERE nodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Access + " WHERE loginNodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Access + " WHERE noAccessNodeId = @id",
"DELETE FROM " + Cms.Core.Constants.DatabaseSchema.Tables.Node + " WHERE id = @id"
};
return list;
}
#endregion
#region Versions
public override IEnumerable GetAllVersions(int nodeId)
{
var sql = GetBaseQuery(QueryType.Many, false)
.Where(x => x.NodeId == nodeId)
.OrderByDescending(x => x.Current)
.AndByDescending(x => x.VersionDate);
return MapDtosToContent(Database.Fetch(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 GetAllVersionsSlim(int nodeId, int skip, int take)
{
var sql = GetBaseQuery(QueryType.Many, false)
.Where(x => x.NodeId == nodeId)
.OrderByDescending(x => x.Current)
.AndByDescending(x => x.VersionDate);
return MapDtosToContent(Database.Fetch(sql), true,
// load bare minimum, need variants though since this is used to rollback with variants
false, false, false, true).Skip(skip).Take(take);
}
public override IContent GetVersion(int versionId)
{
var sql = GetBaseQuery(QueryType.Single, false)
.Where(x => x.Id == versionId);
var dto = Database.Fetch(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
var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetVersion", tsql =>
tsql.Select()
.AndSelect()
.From()
.InnerJoin()
.On((c, d) => c.Id == d.Id)
.Where(x => x.Id == SqlTemplate.Arg("versionId"))
);
var versionDto = Database.Fetch(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.");
else 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
var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetVersions", tsql =>
tsql.Select()
.From()
.InnerJoin()
.On((c, d) => c.Id == d.Id)
.Where(x => x.NodeId == SqlTemplate.Arg("nodeId") && !x.Current && x.VersionDate < SqlTemplate.Arg("versionDate"))
.Where(x => !x.Published)
);
var versionDtos = Database.Fetch(template.Sql(new { nodeId, versionDate }));
foreach (var versionDto in versionDtos)
PerformDeleteVersion(versionDto.NodeId, versionDto.Id);
}
protected override void PerformDeleteVersion(int id, int versionId)
{
Database.Delete("WHERE versionId = @versionId", new { versionId });
Database.Delete("WHERE versionId = @versionId", new { versionId });
Database.Delete("WHERE id = @versionId", new { versionId });
Database.Delete("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
var dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId);
// derive path and level from parent
var parent = GetParentNodeDto(entity.ParentId);
var level = parent.Level + 1;
// get sort order
var sortOrder = GetNewChildSortOrder(entity.ParentId, 0);
// persist the node dto
var 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
var contentDto = dto.ContentDto;
contentDto.NodeId = nodeDto.NodeId;
Database.Insert(contentDto);
// persist the content version dto
var contentVersionDto = dto.DocumentVersionDto.ContentVersionDto;
contentVersionDto.NodeId = nodeDto.NodeId;
contentVersionDto.Current = !publishing;
Database.Insert(contentVersionDto);
entity.VersionId = contentVersionDto.Id;
// persist the document version dto
var 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 propertyDataDtos = PropertyFactory.BuildDtos(entity.ContentType.Variations, entity.VersionId, entity.PublishedVersionId, entity.Properties, LanguageRepository, out var edited, out HashSet 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);
//insert the schedule
PersistContentSchedule(entity, false);
// 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(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($"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($"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
var version = Database.Fetch(SqlContext.Sql().Select().From().Where(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(u => u.Set(x => x.Published, false)).Where(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"))
{
var 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
var dto = ContentBaseFactory.BuildDto(entity, NodeObjectTypeId);
// update the node dto
var 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
var contentVersionDto = dto.DocumentVersionDto.ContentVersionDto;
var documentVersionDto = dto.DocumentVersionDto;
if (publishing)
{
documentVersionDto.Published = true; // now published
contentVersionDto.Current = false; // no more current
}
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;
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 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 (var cultureInfo in entity.CultureInfos)
{
if (cultureInfo.Name != entity.GetPublishName(cultureInfo.Culture))
{
edited = true;
(editedCultures ??= new HashSet(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
var deleteContentVariations = Sql().Delete().Where(x => x.VersionId == versionToDelete);
Database.Execute(deleteContentVariations);
// replace the document version variations (rather than updating)
var deleteDocumentVariations = Sql().Delete().Where(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);
//update the schedule
if (entity.IsPropertyDirty(nameof(entity.ContentSchedule)))
{
PersistContentSchedule(entity, true);
}
// 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($"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($"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");
//}
}
private void PersistContentSchedule(IContent content, bool update)
{
var schedules = ContentBaseFactory.BuildScheduleDto(content, LanguageRepository).ToList();
//remove any that no longer exist
if (update)
{
var ids = schedules.Where(x => x.Model.Id != Guid.Empty).Select(x => x.Model.Id).Distinct();
Database.Execute(Sql()
.Delete()
.Where(x => x.NodeId == content.Id)
.WhereNotIn(x => x.Id, ids));
}
//add/update the rest
foreach (var 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
var subQuery = SqlContext.Sql()
.Select(x => x.AccessId)
.From()
.InnerJoin()
.On(left => left.AccessId, right => right.Id)
.Where(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)
{
var sql = SqlContext.Sql();
if (contentTypeAlias.IsNullOrWhiteSpace())
{
sql.SelectCount()
.From()
.InnerJoin()
.On(left => left.NodeId, right => right.NodeId)
.Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false)
.Where(x => x.Published);
}
else
{
sql.SelectCount()
.From()
.InnerJoin()
.On(left => left.NodeId, right => right.NodeId)
.InnerJoin()
.On(left => left.NodeId, right => right.NodeId)
.InnerJoin()
.On(left => left.NodeId, right => right.ContentTypeId)
.Where(x => x.NodeObjectType == NodeObjectTypeId && x.Trashed == false)
.Where(x => x.Alias == contentTypeAlias)
.Where(x => x.Published);
}
return Database.ExecuteScalar(sql);
}
public void ReplaceContentPermissions(EntityPermissionSet permissionSet)
{
PermissionRepository.ReplaceEntityPermissions(permissionSet);
}
///
/// Assigns a single permission to the current content item for the specified group ids
///
///
///
///
public void AssignEntityPermission(IContent entity, char permission, IEnumerable groupIds)
{
PermissionRepository.AssignEntityPermission(entity, permission, groupIds);
}
public EntityPermissionCollection GetPermissionsForEntity(int entityId)
{
return PermissionRepository.GetPermissionsForEntity(entityId);
}
///
/// Used to add/update a permission for a content item
///
///
public void AddOrUpdatePermissions(ContentPermissionSet permission)
{
PermissionRepository.Save(permission);
}
///
public override IEnumerable GetPage(IQuery query,
long pageIndex, int pageSize, out long totalRecords,
IQuery filter, Ordering ordering)
{
Sql 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(x => x.Name);
filterSql = Sql();
foreach (var 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(query, pageIndex, pageSize, out totalRecords,
x => MapDtosToContent(x),
filterSql,
ordering);
}
public bool IsPathPublished(IContent content)
{
// fail fast
if (content.Path.StartsWith("-1,-20,"))
return false;
// succeed fast
if (content.ParentId == -1)
return content.Published;
var ids = content.Path.Split(Constants.CharArrays.Comma).Skip(1).Select(s => int.Parse(s, CultureInfo.InvariantCulture));
var sql = SqlContext.Sql()
.SelectCount(x => x.NodeId)
.From()
.InnerJoin().On((n, d) => n.NodeId == d.NodeId && d.Published)
.WhereIn(x => x.NodeId, ids);
var count = Database.ExecuteScalar(sql);
return count == content.Level;
}
#endregion
#region Recycle Bin
public override int RecycleBinId => Cms.Core.Constants.System.RecycleBinContent;
public bool RecycleBinSmells()
{
var 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)
{
return _contentByGuidReadRepository.Get(id);
}
IEnumerable IReadRepository.GetMany(params Guid[] ids)
{
return _contentByGuidReadRepository.GetMany(ids);
}
public bool Exists(Guid id)
{
return _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
{
private readonly DocumentRepository _outerRepo;
public ContentByGuidReadRepository(DocumentRepository outerRepo, IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger)
: base(scopeAccessor, cache, logger)
{
_outerRepo = outerRepo;
}
protected override IContent PerformGet(Guid id)
{
var sql = _outerRepo.GetBaseQuery(QueryType.Single)
.Where(x => x.UniqueId == id);
var dto = Database.Fetch(sql.SelectTop(1)).FirstOrDefault();
if (dto == null)
return null;
var content = _outerRepo.MapDtoToContent(dto);
return content;
}
protected override IEnumerable PerformGetAll(params Guid[] ids)
{
var sql = _outerRepo.GetBaseQuery(QueryType.Many);
if (ids.Length > 0)
sql.WhereIn(x => x.UniqueId, ids);
return _outerRepo.MapDtosToContent(Database.Fetch(sql));
}
protected override IEnumerable PerformGetByQuery(IQuery query)
{
throw new InvalidOperationException("This method won't be implemented.");
}
protected override IEnumerable 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 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
///
public void ClearSchedule(DateTime date)
{
var sql = Sql().Delete().Where(x => x.Date <= date);
Database.Execute(sql);
}
///
public void ClearSchedule(DateTime date, ContentScheduleAction action)
{
var a = action.ToString();
var sql = Sql().Delete().Where(x => x.Date <= date && x.Action == a);
Database.Execute(sql);
}
private Sql GetSqlForHasScheduling(ContentScheduleAction action, DateTime date)
{
var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetSqlForHasScheduling", tsql => tsql
.SelectCount()
.From()
.Where(x => x.Action == SqlTemplate.Arg("action") && x.Date <= SqlTemplate.Arg("date")));
var sql = template.Sql(action.ToString(), date);
return sql;
}
public bool HasContentForExpiration(DateTime date)
{
var sql = GetSqlForHasScheduling(ContentScheduleAction.Expire, date);
return Database.ExecuteScalar(sql) > 0;
}
public bool HasContentForRelease(DateTime date)
{
var sql = GetSqlForHasScheduling(ContentScheduleAction.Release, date);
return Database.ExecuteScalar(sql) > 0;
}
///
public IEnumerable GetContentForRelease(DateTime date)
{
var action = ContentScheduleAction.Release.ToString();
var sql = GetBaseQuery(QueryType.Many)
.WhereIn(x => x.NodeId, Sql()
.Select(x => x.NodeId)
.From()
.Where(x => x.Action == action && x.Date <= date));
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch(sql));
}
///
public IEnumerable GetContentForExpiration(DateTime date)
{
var action = ContentScheduleAction.Expire.ToString();
var sql = GetBaseQuery(QueryType.Many)
.WhereIn(x => x.NodeId, Sql()
.Select(x => x.NodeId)
.From()
.Where(x => x.Action == action && x.Date <= date));
AddGetByQueryOrderBy(sql);
return MapDtosToContent(Database.Fetch(sql));
}
#endregion
protected override string ApplySystemOrdering(ref Sql 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"))
{
var joins = Sql()
.InnerJoin("updaterUser").On((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(x => x.UserName, "updaterUser") + " AS ordering "), sql.Arguments);
sql = InsertJoins(sql, joins);
return "ordering";
}
if (ordering.OrderBy.InvariantEquals("published"))
{
// no culture = can only work on the global 'published' flag
if (ordering.Culture.IsNullOrWhiteSpace())
{
// 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
sql = Sql(InsertBefore(sql, "FROM", ", (CASE WHEN pcv.id IS NULL THEN 0 ELSE 1 END) AS ordering "), sql.Arguments);
return "ordering";
}
// 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
var joins = Sql()
.InnerJoin("ctype").On((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(nested =>
nested.InnerJoin("langp").On((ccv, lang) => ccv.LanguageId == lang.Id && lang.IsoCode == "[[[ISOCODE]]]", "ccvp", "langp"), "ccvp")
.On((version, ccv) => version.Id == ccv.VersionId, aliasLeft: "pcv", aliasRight: "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), use the global 'published' flag on pcv.id,
// otherwise check if there's a version culture variation for the lang, via ccv.id
", (CASE WHEN (ctype.variations & 1) = 0 THEN (CASE WHEN pcv.id IS NULL THEN 0 ELSE 1 END) 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 MapDtosToContent(List dtos,
bool withCache = false,
bool loadProperties = true,
bool loadTemplates = true,
bool loadSchedule = true,
bool loadVariants = true)
{
var temps = new List>();
var contentTypes = new Dictionary();
var templateIds = new List();
var content = new Content[dtos.Count];
for (var i = 0; i < dtos.Count; i++)
{
var dto = dtos[i];
if (withCache)
{
// if the cache contains the (proper version of the) item, use it
var cached = IsolatedCache.GetCacheItem(RepositoryCacheKeys.GetKey(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 var contentType) == false)
contentTypes[contentTypeId] = contentType = _contentTypeRepository.Get(contentTypeId);
var c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType);
if (loadTemplates)
{
// need templates
var templateId = dto.DocumentVersionDto.TemplateId;
if (templateId.HasValue && templateId.Value > 0)
templateIds.Add(templateId.Value);
if (dto.Published)
{
templateId = dto.PublishedVersionDto.TemplateId;
if (templateId.HasValue && templateId.Value > 0)
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(dto.NodeId, versionId, publishedVersionId, contentType, c)
{
Template1Id = dto.DocumentVersionDto.TemplateId
};
if (dto.Published)
temp.Template2Id = dto.PublishedVersionDto.TemplateId;
temps.Add(temp);
}
Dictionary 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 properties = null;
if (loadProperties)
{
// load all properties for all documents from database in 1 query - indexed by version id
properties = GetPropertyCollections(temps);
}
var schedule = GetContentSchedule(temps.Select(x => x.Content.Id).ToArray());
// assign templates and properties
foreach (var temp in temps)
{
if (loadTemplates)
{
// set the template ID if it matches an existing template
if (temp.Template1Id.HasValue && templates.ContainsKey(temp.Template1Id.Value))
temp.Content.TemplateId = temp.Template1Id;
if (temp.Template2Id.HasValue && templates.ContainsKey(temp.Template2Id.Value))
temp.Content.PublishTemplateId = temp.Template2Id;
}
// set properties
if (loadProperties)
{
if (properties.ContainsKey(temp.VersionId))
temp.Content.Properties = properties[temp.VersionId];
else
throw new InvalidOperationException($"No property data found for version: '{temp.VersionId}'.");
}
if (loadSchedule)
{
// load in the schedule
if (schedule.TryGetValue(temp.Content.Id, out var s))
temp.Content.ContentSchedule = s;
}
}
if (loadVariants)
{
// set variations, if varying
temps = temps.Where(x => x.ContentType.VariesByCulture()).ToList();
if (temps.Count > 0)
{
// load all variations for all documents from database, in one query
var contentVariations = GetContentVariations(temps);
var documentVariations = GetDocumentVariations(temps);
foreach (var temp in temps)
SetVariations(temp.Content, contentVariations, documentVariations);
}
}
foreach (var c in content)
c.ResetDirtyProperties(false); // reset dirty initial properties (U4-1946)
return content;
}
private IContent MapDtoToContent(DocumentDto dto)
{
var contentType = _contentTypeRepository.Get(dto.ContentDto.ContentTypeId);
var content = ContentBaseFactory.BuildEntity(dto, contentType);
try
{
content.DisableChangeTracking();
// get template
if (dto.DocumentVersionDto.TemplateId.HasValue && dto.DocumentVersionDto.TemplateId.Value > 0)
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(dto.NodeId, versionId, publishedVersionId, contentType);
var ltemp = new List> { temp };
var properties = GetPropertyCollections(ltemp);
content.Properties = properties[dto.DocumentVersionDto.Id];
// set variations, if varying
if (contentType.VariesByCulture())
{
var contentVariations = GetContentVariations(ltemp);
var documentVariations = GetDocumentVariations(ltemp);
SetVariations(content, contentVariations, documentVariations);
}
//load in the schedule
var schedule = GetContentSchedule(dto.NodeId);
if (schedule.TryGetValue(dto.NodeId, out var s))
content.ContentSchedule = s;
// reset dirty initial properties (U4-1946)
content.ResetDirtyProperties(false);
return content;
}
finally
{
content.EnableChangeTracking();
}
}
private IDictionary GetContentSchedule(params int[] contentIds)
{
var result = new Dictionary();
var scheduleDtos = Database.FetchByGroups(contentIds, 2000, batch => Sql()
.Select()
.From()
.WhereIn(x => x.NodeId, batch));
foreach (var scheduleDto in scheduleDtos)
{
if (!result.TryGetValue(scheduleDto.NodeId, out var col))
col = result[scheduleDto.NodeId] = new ContentScheduleCollection();
col.Add(new ContentSchedule(scheduleDto.Id,
LanguageRepository.GetIsoCodeById(scheduleDto.LanguageId) ?? string.Empty,
scheduleDto.Date,
scheduleDto.Action == ContentScheduleAction.Release.ToString()
? ContentScheduleAction.Release
: ContentScheduleAction.Expire));
}
return result;
}
private void SetVariations(Content content, IDictionary> contentVariations, IDictionary> documentVariations)
{
if (contentVariations.TryGetValue(content.VersionId, out var contentVariation))
foreach (var v in contentVariation)
content.SetCultureInfo(v.Culture, v.Name, v.Date);
if (content.PublishedVersionId > 0 && contentVariations.TryGetValue(content.PublishedVersionId, out contentVariation))
{
foreach (var v in contentVariation)
content.SetPublishInfo(v.Culture, v.Name, v.Date);
}
if (documentVariations.TryGetValue(content.Id, out var documentVariation))
content.SetCultureEdited(documentVariation.Where(x => x.Edited).Select(x => x.Culture));
}
private IDictionary> GetContentVariations(List> temps)
where T : class, IContentBase
{
var versions = new List();
foreach (var temp in temps)
{
versions.Add(temp.VersionId);
if (temp.PublishedVersionId > 0)
versions.Add(temp.PublishedVersionId);
}
if (versions.Count == 0)
return new Dictionary>();
var dtos = Database.FetchByGroups(versions, 2000, batch
=> Sql()
.Select()
.From()
.WhereIn(x => x.VersionId, batch));
var variations = new Dictionary>();
foreach (var dto in dtos)
{
if (!variations.TryGetValue(dto.VersionId, out var variation))
variations[dto.VersionId] = variation = new List();
variation.Add(new ContentVariation
{
Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId),
Name = dto.Name,
Date = dto.UpdateDate
});
}
return variations;
}
private IDictionary> GetDocumentVariations(List> temps)
where T : class, IContentBase
{
var ids = temps.Select(x => x.Id);
var dtos = Database.FetchByGroups(ids, 2000, batch =>
Sql()
.Select()
.From()
.WhereIn(x => x.NodeId, batch));
var variations = new Dictionary>();
foreach (var dto in dtos)
{
if (!variations.TryGetValue(dto.NodeId, out var variation))
variations[dto.NodeId] = variation = new List();
variation.Add(new DocumentVariation
{
Culture = LanguageRepository.GetIsoCodeById(dto.LanguageId),
Edited = dto.Edited
});
}
return variations;
}
private IEnumerable GetContentVariationDtos(IContent content, bool publishing)
{
// create dtos for the 'current' (non-published) version, all cultures
// ReSharper disable once UseDeconstruction
foreach (var 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;
// create dtos for the 'published' version, for published cultures (those having a name)
// ReSharper disable once UseDeconstruction
foreach (var 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 GetDocumentVariationDtos(IContent content, HashSet editedCultures)
{
var 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 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 var cultureName)
? 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)
{
return EnsureUniqueNaming == false ? nodeName : base.EnsureUniqueNodeName(parentId, nodeName, id);
}
private SqlTemplate SqlEnsureVariantNamesAreUnique => SqlContext.Templates.Get("Umbraco.Core.DomainRepository.EnsureVariantNamesAreUnique", tsql => tsql
.Select(x => x.Id, x => x.Name, x => x.LanguageId)
.From()
.InnerJoin().On(x => x.Id, x => x.VersionId)
.InnerJoin().On(x => x.NodeId, x => x.NodeId)
.Where(x => x.Current == SqlTemplate.Arg("current"))
.Where(x => x.NodeObjectType == SqlTemplate.Arg("nodeObjectType") &&
x.ParentId == SqlTemplate.Arg("parentId") &&
x.NodeId != SqlTemplate.Arg("id"))
.OrderBy(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)
var sql = SqlEnsureVariantNamesAreUnique.Sql(true, NodeObjectTypeId, content.ParentId, content.Id);
var names = Database.Fetch(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
foreach (var cultureInfo in content.CultureInfos)
{
var langId = LanguageRepository.GetIdByIsoCode(cultureInfo.Culture);
if (!langId.HasValue)
continue;
if (!names.TryGetValue(langId.Value, out var cultureNames))
continue;
// get a unique name
var 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))
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
}
}