using Microsoft.Extensions.Logging; using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Querying; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// /// Represents a repository for doing CRUD operations for /// internal class LanguageRepository : EntityRepositoryBase, ILanguageRepository { private readonly Dictionary _codeIdMap = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _idCodeMap = new(); public LanguageRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) { } private FullDataSetRepositoryCachePolicy? TypedCachePolicy => CachePolicy as FullDataSetRepositoryCachePolicy; public ILanguage? GetByIsoCode(string isoCode) { EnsureCacheIsPopulated(); var id = GetIdByIsoCode(isoCode, false); return id.HasValue ? Get(id.Value) : null; } // fast way of getting an id for an isoCode - avoiding cloning // _codeIdMap is rebuilt whenever PerformGetAll runs public int? GetIdByIsoCode(string? isoCode, bool throwOnNotFound = true) { if (isoCode == null) { return null; } EnsureCacheIsPopulated(); lock (_codeIdMap) { if (_codeIdMap.TryGetValue(isoCode, out var id)) { return id; } } if (throwOnNotFound) { throw new ArgumentException($"Code {isoCode} does not correspond to an existing language.", nameof(isoCode)); } return null; } // fast way of getting an isoCode for an id - avoiding cloning // _idCodeMap is rebuilt whenever PerformGetAll runs public string? GetIsoCodeById(int? id, bool throwOnNotFound = true) { if (id == null) { return null; } EnsureCacheIsPopulated(); // yes, we want to lock _codeIdMap lock (_codeIdMap) { if (_idCodeMap.TryGetValue(id.Value, out var isoCode)) { return isoCode; } } if (throwOnNotFound) { throw new ArgumentException($"Id {id} does not correspond to an existing language.", nameof(id)); } return null; } public string GetDefaultIsoCode() => GetDefault().IsoCode; public int? GetDefaultId() => GetDefault().Id; protected override IRepositoryCachePolicy CreateCachePolicy() => new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ false); protected ILanguage ConvertFromDto(LanguageDto dto) { // yes, we want to lock _codeIdMap lock (_codeIdMap) { string? fallbackIsoCode = null; if (dto.FallbackLanguageId.HasValue && _idCodeMap.TryGetValue(dto.FallbackLanguageId.Value, out fallbackIsoCode) == false) { throw new ArgumentException($"The ISO code map did not contain ISO code for fallback language ID: {dto.FallbackLanguageId}. Please reload the caches."); } return LanguageFactory.BuildEntity(dto, fallbackIsoCode); } } // do NOT leak that language, it's not deep-cloned! private ILanguage GetDefault() { // get all cached var languages = (TypedCachePolicy ?.GetAllCached( PerformGetAll) // Try to get all cached non-cloned if using the correct cache policy (not the case in unit tests) ?? CachePolicy.GetAll(Array.Empty(), PerformGetAll)).ToList(); ILanguage? language = languages.FirstOrDefault(x => x.IsDefault); if (language != null) { return language; } // this is an anomaly, the service/repo should ensure it cannot happen Logger.LogWarning( "There is no default language. Fix this anomaly by editing the language table in database and setting one language as the default language."); // still, don't kill the site, and return "something" ILanguage? first = null; foreach (ILanguage l in languages) { if (first == null || l.Id < first.Id) { first = l; } } return first!; } #region Overrides of RepositoryBase protected override ILanguage? PerformGet(int id) => PerformGetAll(id).FirstOrDefault(); protected override IEnumerable PerformGetAll(params int[]? ids) { Sql sql = GetBaseQuery(false).Where(x => x.Id > 0); if (ids?.Any() ?? false) { sql.WhereIn(x => x.Id, ids); } // this needs to be sorted since that is the way legacy worked - default language is the first one!! // even though legacy didn't sort, it should be by id sql.OrderBy(x => x.Id); // get languages List? languageDtos = Database.Fetch(sql) ?? new List(); // initialize the code-id map if we've reloaded the entire set of languages if (ids?.Any() == false) { lock (_codeIdMap) { _codeIdMap.Clear(); _idCodeMap.Clear(); foreach (LanguageDto languageDto in languageDtos) { ArgumentException.ThrowIfNullOrEmpty(languageDto.IsoCode, nameof(LanguageDto.IsoCode)); _codeIdMap[languageDto.IsoCode] = languageDto.Id; _idCodeMap[languageDto.Id] = languageDto.IsoCode; } } } var languages = languageDtos.Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); return languages; } protected override IEnumerable PerformGetByQuery(IQuery query) { Sql sqlClause = GetBaseQuery(false); var translator = new SqlTranslator(sqlClause, query); Sql sql = translator.Translate(); List? dtos = Database.Fetch(sql); return dtos.Select(ConvertFromDto).ToList(); } #endregion #region Overrides of EntityRepositoryBase protected override Sql GetBaseQuery(bool isCount) { Sql sql = Sql(); sql = isCount ? sql.SelectCount() : sql.Select(); sql.From(); return sql; } protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Language}.id = @id"; protected override IEnumerable GetDeleteClauses() { var list = new List { // NOTE: There is no constraint between the Language and cmsDictionary/cmsLanguageText tables (?) // but we still need to remove them "DELETE FROM " + Constants.DatabaseSchema.Tables.DictionaryValue + " WHERE languageId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE languageId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersionCultureVariation + " WHERE languageId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.DocumentCultureVariation + " WHERE languageId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE tagId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.Tag + " WHERE languageId = @id)", "DELETE FROM " + Constants.DatabaseSchema.Tables.Tag + " WHERE languageId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Language + " WHERE id = @id", }; return list; } #endregion #region Unit of Work Implementation protected override void PersistNewItem(ILanguage entity) { // validate iso code and culture name if (entity.IsoCode.IsNullOrWhiteSpace() || entity.CultureName.IsNullOrWhiteSpace()) { throw new InvalidOperationException("Cannot save a language without an ISO code and a culture name."); } EnsureCacheIsPopulated(); entity.AddingEntity(); // deal with entity becoming the new default entity if (entity.IsDefault) { // set all other entities to non-default // safe (no race cond) because the service locks languages Sql setAllDefaultToFalse = Sql() .Update(u => u.Set(x => x.IsDefault, false)); Database.Execute(setAllDefaultToFalse); } // fallback cycles are detected at service level // insert LanguageDto dto = LanguageFactory.BuildDto(entity, GetFallbackLanguageId(entity)); var id = Convert.ToInt32(Database.Insert(dto)); entity.Id = id; entity.ResetDirtyProperties(); // yes, we want to lock _codeIdMap lock (_codeIdMap) { _codeIdMap[entity.IsoCode] = entity.Id; _idCodeMap[entity.Id] = entity.IsoCode; } } protected override void PersistUpdatedItem(ILanguage entity) { // validate iso code and culture name if (entity.IsoCode.IsNullOrWhiteSpace() || entity.CultureName.IsNullOrWhiteSpace()) { throw new InvalidOperationException("Cannot save a language without an ISO code and a culture name."); } EnsureCacheIsPopulated(); entity.UpdatingEntity(); if (entity.IsDefault) { // deal with entity becoming the new default entity // set all other entities to non-default // safe (no race cond) because the service locks languages Sql setAllDefaultToFalse = Sql() .Update(u => u.Set(x => x.IsDefault, false)); Database.Execute(setAllDefaultToFalse); } else { // deal with the entity not being default anymore // which is illegal - another entity has to become default Sql selectDefaultId = Sql() .Select(x => x.Id) .From() .Where(x => x.IsDefault); var defaultId = Database.ExecuteScalar(selectDefaultId); if (entity.Id == defaultId) { throw new InvalidOperationException( $"Cannot save the default language ({entity.IsoCode}) as non-default. Make another language the default language instead."); } } if (entity.IsPropertyDirty(nameof(ILanguage.IsoCode))) { // If the iso code is changing, ensure there's not another lang with the same code already assigned Sql sameCode = Sql() .SelectCount() .From() .Where(x => x.IsoCode == entity.IsoCode && x.Id != entity.Id); var countOfSameCode = Database.ExecuteScalar(sameCode); if (countOfSameCode > 0) { throw new InvalidOperationException( $"Cannot update the language to a new culture: {entity.IsoCode} since that culture is already assigned to another language entity."); } } // fallback cycles are detected at service level // update LanguageDto dto = LanguageFactory.BuildDto(entity, GetFallbackLanguageId(entity)); Database.Update(dto); entity.ResetDirtyProperties(); // yes, we want to lock _codeIdMap lock (_codeIdMap) { _codeIdMap.RemoveAll(kvp => kvp.Value == entity.Id); _codeIdMap[entity.IsoCode] = entity.Id; _idCodeMap[entity.Id] = entity.IsoCode; } } protected override void PersistDeletedItem(ILanguage entity) { // validate that the entity is not the default language. // safe (no race cond) because the service locks languages Sql selectDefaultId = Sql() .Select(x => x.Id) .From() .Where(x => x.IsDefault); var defaultId = Database.ExecuteScalar(selectDefaultId); if (entity.Id == defaultId) { throw new InvalidOperationException($"Cannot delete the default language ({entity.IsoCode})."); } // We need to remove any references to the language if it's being used as a fall-back from other ones Sql clearFallbackLanguage = Sql() .Update(u => u .Set(x => x.FallbackLanguageId, null)) .Where(x => x.FallbackLanguageId == entity.Id); Database.Execute(clearFallbackLanguage); // delete base.PersistDeletedItem(entity); // yes, we want to lock _codeIdMap lock (_codeIdMap) { _codeIdMap.RemoveAll(kvp => kvp.Value == entity.Id); _idCodeMap.Remove(entity.Id); } } private void EnsureCacheIsPopulated() { // ensure cache is populated, in a non-expensive way if (TypedCachePolicy != null) { TypedCachePolicy.GetAllCached(PerformGetAll); } else { PerformGetAll(); // We don't have a typed cache (i.e. unit tests) but need to populate the _codeIdMap } } private int? GetFallbackLanguageId(ILanguage entity) { int? fallbackLanguageId = null; if (entity.FallbackIsoCode.IsNullOrWhiteSpace() == false && _codeIdMap.TryGetValue(entity.FallbackIsoCode, out var languageId)) { fallbackLanguageId = languageId; } return fallbackLanguageId; } #endregion }