using System; using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Scoping; using Umbraco.Core.Security; using Umbraco.Core.Services; using static Umbraco.Core.Persistence.SqlExtensionsStatics; namespace Umbraco.Core.Persistence.Repositories.Implement { /// /// Represents a repository for doing CRUD operations for /// public class MemberRepository : ContentRepositoryBase, IMemberRepository { private readonly IMemberTypeRepository _memberTypeRepository; private readonly ITagRepository _tagRepository; private readonly IPasswordHasher _passwordHasher; private readonly IMemberGroupRepository _memberGroupRepository; public MemberRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IMemberTypeRepository memberTypeRepository, IMemberGroupRepository memberGroupRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, IPasswordHasher passwordHasher, Lazy propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories, IDataTypeService dataTypeService) : base(scopeAccessor, cache, logger, languageRepository, relationRepository, relationTypeRepository, propertyEditors, dataValueReferenceFactories, dataTypeService) { _memberTypeRepository = memberTypeRepository ?? throw new ArgumentNullException(nameof(memberTypeRepository)); _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); _passwordHasher = passwordHasher; _memberGroupRepository = memberGroupRepository; } protected override MemberRepository This => this; public override int RecycleBinId => throw new NotSupportedException(); #region Repository Base protected override Guid NodeObjectTypeId => Constants.ObjectTypes.Member; protected override IMember 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 baseQuery = GetBaseQuery(false); // TODO: why is this different from content/media?! // check if the query is based on properties or not var wheres = query.GetWhereClauses(); //this is a pretty rudimentary check but will work, we just need to know if this query requires property // level queries if (wheres.Any(x => x.Item1.Contains("cmsPropertyType"))) { var sqlWithProps = GetNodeIdQueryWithPropertyData(); var translator = new SqlTranslator(sqlWithProps, query); var sql = translator.Translate(); baseQuery.Append("WHERE umbracoNode.id IN (" + sql.SQL + ")", sql.Arguments) .OrderBy(x => x.SortOrder); return MapDtosToContent(Database.Fetch(baseQuery)); } else { var translator = new SqlTranslator(baseQuery, query); var sql = translator.Translate() .OrderBy(x => x.SortOrder); return MapDtosToContent(Database.Fetch(sql)); } } protected override Sql GetBaseQuery(QueryType queryType) { return GetBaseQuery(queryType, true); } protected virtual Sql GetBaseQuery(QueryType queryType, bool current) { var sql = SqlContext.Sql(); switch (queryType) // TODO: pretend we still need these queries for now { case QueryType.Count: sql = sql.SelectCount(); break; case QueryType.Ids: sql = sql.Select(x => x.NodeId); break; case QueryType.Single: case QueryType.Many: sql = sql.Select(r => r.Select(x => x.ContentVersionDto) .Select(x => x.ContentDto, r1 => r1.Select(x => x.NodeDto))) // ContentRepositoryBase expects a variantName field to order by name // so get it here, though for members it's just the plain node name .AndSelect(x => Alias(x.Text, "variantName")); break; } sql .From() .InnerJoin().On(left => left.NodeId, right => right.NodeId) .InnerJoin().On(left => left.NodeId, right => right.NodeId) .InnerJoin().On(left => left.NodeId, right => right.NodeId) // joining the type so we can do a query against the member type - not sure if this adds much overhead or not? // the execution plan says it doesn't so we'll go with that and in that case, it might be worth joining the content // types by default on the document and media repos so we can query by content type there too. .InnerJoin().On(left => left.ContentTypeId, right => right.NodeId); sql.Where(x => x.NodeObjectType == NodeObjectTypeId); if (current) sql.Where(x => x.Current); // always get the current version return sql; } // TODO: move that one up to Versionable! or better: kill it! protected override Sql GetBaseQuery(bool isCount) { return GetBaseQuery(isCount ? QueryType.Count : QueryType.Single); } protected override string GetBaseWhereClause() // TODO: can we kill / refactor this? { return "umbracoNode.id = @id"; } // TODO: document/understand that one protected Sql GetNodeIdQueryWithPropertyData() { return Sql() .Select("DISTINCT(umbracoNode.id)") .From() .InnerJoin().On((left, right) => left.NodeId == right.NodeId) .InnerJoin().On((left, right) => left.ContentTypeId == right.NodeId) .InnerJoin().On((left, right) => left.NodeId == right.NodeId) .InnerJoin().On((left, right) => left.NodeId == right.NodeId) .LeftJoin().On(left => left.ContentTypeId, right => right.ContentTypeId) .LeftJoin().On(left => left.DataTypeId, right => right.NodeId) .LeftJoin().On(x => x .Where((left, right) => left.PropertyTypeId == right.Id) .Where((left, right) => left.VersionId == right.Id)) .Where(x => x.NodeObjectType == NodeObjectTypeId); } protected override IEnumerable GetDeleteClauses() { var list = new List { "DELETE FROM umbracoUser2NodeNotify WHERE nodeId = @id", "DELETE FROM umbracoUserGroup2NodePermission WHERE nodeId = @id", "DELETE FROM umbracoRelation WHERE parentId = @id", "DELETE FROM umbracoRelation WHERE childId = @id", "DELETE FROM cmsTagRelationship WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", "DELETE FROM cmsMember2MemberGroup WHERE Member = @id", "DELETE FROM cmsMember WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id", "DELETE FROM umbracoNode 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); } public override IMember GetVersion(int versionId) { var sql = GetBaseQuery(QueryType.Single) .Where(x => x.Id == versionId); var dto = Database.Fetch(sql).FirstOrDefault(); return dto == null ? null : MapDtoToContent(dto); } protected override void PerformDeleteVersion(int id, int versionId) { // raise event first else potential FK issues OnUowRemovingVersion(new ScopedVersionEventArgs(AmbientScope, id, versionId)); Database.Delete("WHERE versionId = @VersionId", new { versionId }); Database.Delete("WHERE versionId = @VersionId", new { versionId }); } #endregion #region Persist protected override void PersistNewItem(IMember entity) { entity.AddingEntity(); var member = (Member) entity; // 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); // 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; nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); nodeDto.ValidatePathWithException(); Database.Update(nodeDto); } else { Database.Insert(nodeDto); // update path, now that we have an id 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 // assumes a new version id and version date (modified date) has been set var contentVersionDto = dto.ContentVersionDto; contentVersionDto.NodeId = nodeDto.NodeId; contentVersionDto.Current = true; Database.Insert(contentVersionDto); member.VersionId = contentVersionDto.Id; // persist the member dto dto.NodeId = nodeDto.NodeId; // if the password is empty, generate one with the special prefix // this will hash the guid with a salt so should be nicely random if (entity.RawPasswordValue.IsNullOrWhiteSpace()) { dto.Password = Constants.Security.EmptyPasswordPrefix + _passwordHasher.HashPassword(Guid.NewGuid().ToString("N")); entity.RawPasswordValue = dto.Password; } Database.Insert(dto); // persist the property data var propertyDataDtos = PropertyFactory.BuildDtos(member.ContentType.Variations, member.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); foreach (var propertyDataDto in propertyDataDtos) Database.Insert(propertyDataDto); SetEntityTags(entity, _tagRepository); PersistRelations(entity); OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); } protected override void PersistUpdatedItem(IMember entity) { var member = (Member) entity; // update member.UpdatingEntity(); // 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); // update the node dto var nodeDto = dto.ContentDto.NodeDto; Database.Update(nodeDto); // update the content dto Database.Update(dto.ContentDto); // update the content version dto Database.Update(dto.ContentVersionDto); // update the member dto // but only the changed columns, 'cos we cannot update password if empty var changedCols = new List(); if (entity.IsPropertyDirty("Email")) changedCols.Add("Email"); if (entity.IsPropertyDirty("Username")) changedCols.Add("LoginName"); // do NOT update the password if it has not changed or if it is null or empty if (entity.IsPropertyDirty("RawPasswordValue") && !string.IsNullOrWhiteSpace(entity.RawPasswordValue)) changedCols.Add("Password"); if (changedCols.Count > 0) Database.Update(dto, changedCols); // replace the property data var deletePropertyDataSql = SqlContext.Sql().Delete().Where(x => x.VersionId == member.VersionId); Database.Execute(deletePropertyDataSql); var propertyDataDtos = PropertyFactory.BuildDtos(member.ContentType.Variations, member.VersionId, 0, entity.Properties, LanguageRepository, out _, out _); foreach (var propertyDataDto in propertyDataDtos) Database.Insert(propertyDataDto); SetEntityTags(entity, _tagRepository); PersistRelations(entity); OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); } protected override void PersistDeletedItem(IMember entity) { // raise event first else potential FK issues OnUowRemovingEntity(new ScopedEntityEventArgs(AmbientScope, entity)); base.PersistDeletedItem(entity); } #endregion public IEnumerable FindMembersInRole(string roleName, string usernameToMatch, StringPropertyMatchType matchType = StringPropertyMatchType.StartsWith) { //get the group id var grpQry = Query().Where(group => group.Name.Equals(roleName)); var memberGroup = _memberGroupRepository.Get(grpQry).FirstOrDefault(); if (memberGroup == null) return Enumerable.Empty(); // get the members by username var query = Query(); switch (matchType) { case StringPropertyMatchType.Exact: query.Where(member => member.Username.Equals(usernameToMatch)); break; case StringPropertyMatchType.Contains: query.Where(member => member.Username.Contains(usernameToMatch)); break; case StringPropertyMatchType.StartsWith: query.Where(member => member.Username.StartsWith(usernameToMatch)); break; case StringPropertyMatchType.EndsWith: query.Where(member => member.Username.EndsWith(usernameToMatch)); break; case StringPropertyMatchType.Wildcard: query.Where(member => member.Username.SqlWildcard(usernameToMatch, TextColumnType.NVarchar)); break; default: throw new ArgumentOutOfRangeException(nameof(matchType)); } var matchedMembers = Get(query).ToArray(); var membersInGroup = new List(); //then we need to filter the matched members that are in the role //since the max sql params are 2100 on sql server, we'll reduce that to be safe for potentially other servers and run the queries in batches var inGroups = matchedMembers.InGroupsOf(1000); foreach (var batch in inGroups) { var memberIdBatch = batch.Select(x => x.Id); var sql = Sql().SelectAll().From() .Where(dto => dto.MemberGroup == memberGroup.Id) .Where("Member IN (@memberIds)", new { memberIds = memberIdBatch }); var memberIdsInGroup = Database.Fetch(sql) .Select(x => x.Member).ToArray(); membersInGroup.AddRange(matchedMembers.Where(x => memberIdsInGroup.Contains(x.Id))); } return membersInGroup; } /// /// Get all members in a specific group /// /// /// public IEnumerable GetByMemberGroup(string groupName) { var grpQry = Query().Where(group => group.Name.Equals(groupName)); var memberGroup = _memberGroupRepository.Get(grpQry).FirstOrDefault(); if (memberGroup == null) return Enumerable.Empty(); var subQuery = Sql().Select("Member").From().Where(dto => dto.MemberGroup == memberGroup.Id); var sql = GetBaseQuery(false) // TODO: An inner join would be better, though I've read that the query optimizer will always turn a // subquery with an IN clause into an inner join anyways. .Append("WHERE umbracoNode.id IN (" + subQuery.SQL + ")", subQuery.Arguments) .OrderByDescending(x => x.VersionDate) .OrderBy(x => x.SortOrder); return MapDtosToContent(Database.Fetch(sql)); } public bool Exists(string username) { var sql = Sql() .SelectCount() .From() .Where(x => x.LoginName == username); return Database.ExecuteScalar(sql) > 0; } public int GetCountByQuery(IQuery query) { var sqlWithProps = GetNodeIdQueryWithPropertyData(); var translator = new SqlTranslator(sqlWithProps, query); var sql = translator.Translate(); //get the COUNT base query var fullSql = GetBaseQuery(true) .Append(new Sql("WHERE umbracoNode.id IN (" + sql.SQL + ")", sql.Arguments)); return Database.ExecuteScalar(fullSql); } /// /// Gets paged member results. /// public override IEnumerable GetPage(IQuery query, long pageIndex, int pageSize, out long totalRecords, IQuery filter, Ordering ordering) { Sql filterSql = null; if (filter != null) { filterSql = Sql(); foreach (var clause in filter.GetWhereClauses()) filterSql = filterSql.Append($"AND ({clause.Item1})", clause.Item2); } return GetPage(query, pageIndex, pageSize, out totalRecords, x => MapDtosToContent(x), filterSql, ordering); } private string _pagedResultsByQueryWhere; private string GetPagedResultsByQueryWhere() { if (_pagedResultsByQueryWhere == null) _pagedResultsByQueryWhere = " AND (" + $"({SqlSyntax.GetQuotedTableName("umbracoNode")}.{SqlSyntax.GetQuotedColumnName("text")} LIKE @0)" + " OR " + $"({SqlSyntax.GetQuotedTableName("cmsMember")}.{SqlSyntax.GetQuotedColumnName("LoginName")} LIKE @0)" + ")"; return _pagedResultsByQueryWhere; } protected override string ApplySystemOrdering(ref Sql sql, Ordering ordering) { if (ordering.OrderBy.InvariantEquals("email")) return SqlSyntax.GetFieldName(x => x.Email); if (ordering.OrderBy.InvariantEquals("loginName")) return SqlSyntax.GetFieldName(x => x.LoginName); if (ordering.OrderBy.InvariantEquals("userName")) return SqlSyntax.GetFieldName(x => x.LoginName); if (ordering.OrderBy.InvariantEquals("updateDate")) return SqlSyntax.GetFieldName(x => x.VersionDate); if (ordering.OrderBy.InvariantEquals("createDate")) return SqlSyntax.GetFieldName(x => x.CreateDate); if (ordering.OrderBy.InvariantEquals("contentTypeAlias")) return SqlSyntax.GetFieldName(x => x.Alias); return base.ApplySystemOrdering(ref sql, ordering); } private IEnumerable MapDtosToContent(List dtos, bool withCache = false) { var temps = new List>(); var contentTypes = new Dictionary(); var content = new Member[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.ContentVersionDto.Id) { content[i] = (Member) 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 = _memberTypeRepository.Get(contentTypeId); var c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType); // need properties var versionId = dto.ContentVersionDto.Id; temps.Add(new TempContent(dto.NodeId, versionId, 0, contentType, c)); } // load all properties for all documents from database in 1 query - indexed by version id var properties = GetPropertyCollections(temps); // assign properties foreach (var temp in temps) { temp.Content.Properties = properties[temp.VersionId]; // reset dirty initial properties (U4-1946) temp.Content.ResetDirtyProperties(false); } return content; } private IMember MapDtoToContent(MemberDto dto) { var memberType = _memberTypeRepository.Get(dto.ContentDto.ContentTypeId); var member = ContentBaseFactory.BuildEntity(dto, memberType); // get properties - indexed by version id var versionId = dto.ContentVersionDto.Id; var temp = new TempContent(dto.ContentDto.NodeId,versionId, 0, memberType); var properties = GetPropertyCollections(new List> { temp }); member.Properties = properties[versionId]; // reset dirty initial properties (U4-1946) member.ResetDirtyProperties(false); return member; } } }