Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Umbraco.Core/Configuration/Models/DictionarySettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.

using System.ComponentModel;

namespace Umbraco.Cms.Core.Configuration.Models;

/// <summary>
/// Typed configuration options for dictionary settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigDictionary)]
public class DictionarySettings
{
private const bool StaticEnableValueSearch = false;

/// <summary>
/// Gets or sets a value indicating whether to enable searching in dictionary values in addition to keys.
/// </summary>
/// <remarks>
/// When enabled, the GetDictionaryItemDescendants method will search both dictionary keys and translation values.
/// This may impact performance when dealing with large numbers of dictionary items.
/// </remarks>
[DefaultValue(StaticEnableValueSearch)]
public bool EnableValueSearch { get; set; } = StaticEnableValueSearch;
}
1 change: 1 addition & 0 deletions src/Umbraco.Core/Constants-Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public static class Configuration
public const string ConfigCache = ConfigPrefix + "Cache";
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie";
public const string ConfigDictionary = ConfigPrefix + "Dictionary";

public static class NamedOptions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder)
.AddUmbracoOptions<ContentSettings>()
.AddUmbracoOptions<DeliveryApiSettings>()
.AddUmbracoOptions<CoreDebugSettings>()
.AddUmbracoOptions<DictionarySettings>()
.AddUmbracoOptions<ExceptionFilterSettings>()
.AddUmbracoOptions<GlobalSettings>(optionsBuilder => optionsBuilder.PostConfigure(options =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

Check notice on line 1 in src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

✅ Getting better: Primitive Obsession

The ratio of primitive types in function arguments decreases from 40.00% to 39.62%, threshold = 30.0%. The functions in this file have too many primitive types (e.g. int, double, float) in their function argument lists. Using many primitive types lead to the code smell Primitive Obsession. Avoid adding more primitive arguments.
using Microsoft.Extensions.Options;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Querying;
Expand All @@ -23,6 +23,7 @@
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILanguageRepository _languageRepository;
private readonly IOptionsMonitor<DictionarySettings> _dictionarySettings;

private string QuotedColumn(string columnName) => $"{QuoteTableName(DictionaryDto.TableName)}.{QuoteColumnName(columnName)}";

Expand All @@ -33,11 +34,13 @@
ILoggerFactory loggerFactory,
ILanguageRepository languageRepository,
IRepositoryCacheVersionService repositoryCacheVersionService,
ICacheSyncService cacheSyncService)
ICacheSyncService cacheSyncService,
IOptionsMonitor<DictionarySettings> dictionarySettings)
: base(scopeAccessor, cache, logger, repositoryCacheVersionService, cacheSyncService)
{
_loggerFactory = loggerFactory;
_languageRepository = languageRepository;
_dictionarySettings = dictionarySettings;
}

public IDictionaryItem? Get(Guid uniqueId)
Expand Down Expand Up @@ -110,11 +113,7 @@
.Where<DictionaryDto>(x => x.Parent != null)
.WhereIn<DictionaryDto>(x => x.Parent, group);

if (filter.IsNullOrWhiteSpace() is false)
{
sql.Where<DictionaryDto>(x => x.Key.StartsWith(filter));
}

ApplyFilterToQuery(sql, filter);
sql.OrderBy<DictionaryDto>(x => x.UniqueId);

return Database
Expand All @@ -128,10 +127,7 @@
Sql<ISqlContext> sql = GetBaseQuery(false)
.Where<DictionaryDto>(x => x.PrimaryKey > 0);

if (filter.IsNullOrWhiteSpace() is false)
{
sql.Where<DictionaryDto>(x => x.Key.StartsWith(filter));
}
ApplyFilterToQuery(sql, filter);

return Database
.FetchOneToMany<DictionaryDto>(x => x.LanguageTextDtos, sql)
Expand All @@ -147,6 +143,35 @@
string DictionaryItemOrdering(IDictionaryItem item) => item.ItemKey;
}

/// <summary>
/// Applies the filter condition to the SQL query based on configuration settings.
/// </summary>
/// <param name="sql">The SQL query to modify.</param>
/// <param name="filter">The filter string to apply, or null if no filter should be applied.</param>
private void ApplyFilterToQuery(Sql<ISqlContext> sql, string? filter)
{
if (filter.IsNullOrWhiteSpace())
{
return;
}

if (_dictionarySettings.CurrentValue.EnableValueSearch)
{
// Search in both keys and values
// Use a subquery to find dictionary items that have matching translations
// Then fetch ALL translations for those items
sql.Where(
$"({QuotedColumn("key")} LIKE @0 OR {QuotedColumn("id")} IN (SELECT DISTINCT {QuoteColumnName("UniqueId")} FROM {QuoteTableName(LanguageTextDto.TableName)} WHERE {QuoteColumnName("value")} LIKE @1))",
$"{filter}%",
$"%{filter}%");
}
else
{
// Search only in keys
sql.Where<DictionaryDto>(x => x.Key.StartsWith(filter));
}
}

protected override IRepositoryCachePolicy<IDictionaryItem, int> CreateCachePolicy()
{
var options = new RepositoryCachePolicyOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
// See LICENSE for more details.

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Services;
Expand All @@ -25,17 +27,22 @@ internal sealed class DictionaryRepositoryTest : UmbracoIntegrationTest

private IDictionaryRepository CreateRepository() => GetRequiredService<IDictionaryRepository>();

private IDictionaryRepository CreateRepositoryWithCache(AppCaches cache) =>
private IDictionaryRepository CreateRepositoryWithCache(AppCaches cache, bool enableValueSearch = false)
{
var dictionarySettingsMonitor = new Mock<IOptionsMonitor<DictionarySettings>>();
dictionarySettingsMonitor.Setup(x => x.CurrentValue).Returns(new DictionarySettings { EnableValueSearch = enableValueSearch });

// Create a repository with a real runtime cache.
new DictionaryRepository(
return new DictionaryRepository(
GetRequiredService<IScopeAccessor>(),
cache,
GetRequiredService<ILogger<DictionaryRepository>>(),
GetRequiredService<ILoggerFactory>(),
GetRequiredService<ILanguageRepository>(),
GetRequiredService<IRepositoryCacheVersionService>(),
GetRequiredService<ICacheSyncService>());
GetRequiredService<ICacheSyncService>(),
dictionarySettingsMonitor.Object);
}

[Test]
public async Task Can_Perform_Get_By_Key_On_DictionaryRepository()
Expand Down Expand Up @@ -539,6 +546,50 @@ public void Cannot_Perform_Cached_Request_For_NonExisting_Value_By_Key_On_Dictio
}
}

[Test]
public void GetDictionaryItemDescendants_WithValueSearch_Disabled_Does_Not_Return_Items_Matching_Only_Translation_Value()
{
// Arrange
var cache = AppCaches.Create(Mock.Of<IRequestCache>());
var repository = CreateRepositoryWithCache(cache, enableValueSearch: false);

using (ScopeProvider.CreateScope())
{
// Act - Search for "Læs" which only exists in Danish translation value, not in any key
var results = repository.GetDictionaryItemDescendants(null, "Læs").ToArray();

// Assert - Should not find anything because value search is disabled
Assert.That(results, Is.Empty);
}
}

[Test]
public void GetDictionaryItemDescendants_WithValueSearch_Enabled_Returns_Items_Matching_Translation_Value()
{
// Arrange
var cache = AppCaches.Create(Mock.Of<IRequestCache>());
var repository = CreateRepositoryWithCache(cache, enableValueSearch: true);

using (ScopeProvider.CreateScope())
{
// Act - Search for "Læs" which only exists in Danish translation value, not in any key
var results = repository.GetDictionaryItemDescendants(null, "Læs").ToArray();

// Assert - Should find "Read More" because its Danish translation contains "Læs mere"
Assert.That(results, Has.Length.EqualTo(1));
Assert.That(results[0].ItemKey, Is.EqualTo("Read More"));

// - also verify that both languages have a translation
var translatedIsoCodes = results[0]
.Translations
.Where(translation => translation.Value.IsNullOrWhiteSpace() == false)
.Select(translation => translation.LanguageIsoCode)
.ToArray();
Assert.That(translatedIsoCodes, Does.Contain("en-US"));
Assert.That(translatedIsoCodes, Does.Contain("da-DK"));
}
}

public async Task CreateTestData()
{
var languageService = GetRequiredService<ILanguageService>();
Expand Down
Loading