From 05f980489fba8f17d7ecaec62845f6d3e38d0887 Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:08:15 +0800 Subject: [PATCH 1/6] Cap missing language resource markers, and fetch a form's definitions at once over a remote client LanguageResourceCache keeps its miss markers in a capped set, like the session and API key caches: the language and namespace come from any signed-in caller of System.GetLanguage. FormDefinitionLoader fetches the layout layers and the language resources of the fall-back chain concurrently when the client is remote, where each fetch is a round trip; an in-process client keeps fetching one after another, since its calls run on the caller's services. --- .../Definitions/FormDefinitionLoader.cs | 59 ++++++++++--- .../Define/LanguageResourceCache.cs | 14 +++ .../FormDefinitionLoaderFetchTests.cs | 85 +++++++++++++++++++ .../Database/NegativeCacheBoundTests.cs | 27 +++++- 4 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 tests/Polhem.Api.Client.UnitTests/Definitions/FormDefinitionLoaderFetchTests.cs diff --git a/src/Polhem.Api.Client/Definitions/FormDefinitionLoader.cs b/src/Polhem.Api.Client/Definitions/FormDefinitionLoader.cs index b1b82fd9..ea267a8f 100644 --- a/src/Polhem.Api.Client/Definitions/FormDefinitionLoader.cs +++ b/src/Polhem.Api.Client/Definitions/FormDefinitionLoader.cs @@ -136,8 +136,20 @@ public async Task GetRuntimeLayoutAsync(string progId, FormSchema lo ArgumentNullException.ThrowIfNull(localizedSchema); string effectiveLayoutId = string.IsNullOrWhiteSpace(layoutId) ? progId : layoutId; - var customize = await _defineAccess.GetCustomizeFormLayoutAsync(progId, effectiveLayoutId, cancellationToken).ConfigureAwait(false); - var @base = await _defineAccess.GetFormLayoutAsync(effectiveLayoutId, cancellationToken).ConfigureAwait(false); + FormLayout? customize, @base; + if (FetchConcurrently) + { + var customizeTask = _defineAccess.GetCustomizeFormLayoutAsync(progId, effectiveLayoutId, cancellationToken); + var baseTask = _defineAccess.GetFormLayoutAsync(effectiveLayoutId, cancellationToken); + await Task.WhenAll((Task)customizeTask, baseTask).ConfigureAwait(false); + customize = await customizeTask.ConfigureAwait(false); + @base = await baseTask.ConfigureAwait(false); + } + else + { + customize = await _defineAccess.GetCustomizeFormLayoutAsync(progId, effectiveLayoutId, cancellationToken).ConfigureAwait(false); + @base = await _defineAccess.GetFormLayoutAsync(effectiveLayoutId, cancellationToken).ConfigureAwait(false); + } var definition = CustomizeOverlay.PickFormLayout(customize, @base) ?? throw new InvalidOperationException( @@ -200,19 +212,46 @@ private async Task BuildLanguageServiceAsync(FormSchema private async Task> FetchLayersAsync(IEnumerable namespaces, string lang, string defaultLanguage, CancellationToken cancellationToken) { - var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string language in LanguageFallback.GetChain(lang, defaultLanguage)) + var pairs = LanguageFallback.GetChain(lang, defaultLanguage) + .SelectMany(language => namespaces.Select(ns => (Language: language, Namespace: ns))) + .ToList(); + var layers = new LanguageLayers[pairs.Count]; + if (FetchConcurrently) { - foreach (string ns in namespaces) - { - var customize = await _defineAccess.GetCustomizeLanguageAsync(language, ns, cancellationToken).ConfigureAwait(false); - var @base = await SafeGetLanguageAsync(language, ns, cancellationToken).ConfigureAwait(false); - snapshot[SnapshotLanguageService.BuildKey(language, ns)] = new LanguageLayers(@base, customize); - } + await Task.WhenAll(pairs.Select(async (pair, i) => + layers[i] = await FetchLayersAsync(pair.Language, pair.Namespace, cancellationToken).ConfigureAwait(false))) + .ConfigureAwait(false); } + else + { + for (int i = 0; i < pairs.Count; i++) + layers[i] = await FetchLayersAsync(pairs[i].Language, pairs[i].Namespace, cancellationToken).ConfigureAwait(false); + } + + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < pairs.Count; i++) + snapshot[SnapshotLanguageService.BuildKey(pairs[i].Language, pairs[i].Namespace)] = layers[i]; return snapshot; } + private async Task FetchLayersAsync(string language, string ns, CancellationToken cancellationToken) + { + var customize = await _defineAccess.GetCustomizeLanguageAsync(language, ns, cancellationToken).ConfigureAwait(false); + var @base = await SafeGetLanguageAsync(language, ns, cancellationToken).ConfigureAwait(false); + return new LanguageLayers(@base, customize); + } + + /// + /// Gets a value indicating whether the definitions a form needs are fetched at once rather than one after + /// another. + /// + /// + /// Only a remote client gains: each fetch is a round trip, and on a cold cache a form needs one per layer, + /// namespace and culture of the fall-back chain. An in-process client has no round trip to save, and its calls + /// run on the caller's services, which are not meant to be used by several calls at once. + /// + private bool FetchConcurrently => !_defineAccess.Client.IsLocal; + private static HashSet CollectNamespaces(FormSchema schema) { var namespaces = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/Polhem.ObjectCaching/Define/LanguageResourceCache.cs b/src/Polhem.ObjectCaching/Define/LanguageResourceCache.cs index 29ea9a78..0babcb77 100644 --- a/src/Polhem.ObjectCaching/Define/LanguageResourceCache.cs +++ b/src/Polhem.ObjectCaching/Define/LanguageResourceCache.cs @@ -14,6 +14,17 @@ public sealed class LanguageResourceCache : KeyObjectCache { private readonly IDefineStorage _storage; + /// + /// Upper bound on the number of missing language resources remembered at once. + /// + /// + /// The language and namespace of a lookup come from the caller (System.GetLanguage takes them from any + /// signed-in user), so the miss markers are capped rather than kept in the shared cache provider. The resources + /// an application really looks up and lacks are a handful per language; past the cap a miss goes uncached and + /// costs one storage read. + /// + internal static readonly int MaxNegativeLanguages = 10_000; + /// /// Initializes a new instance of . /// @@ -24,6 +35,9 @@ public LanguageResourceCache(IDefineStorage storage, string cachePrefix = "") : _storage = storage ?? throw new ArgumentNullException(nameof(storage)); } + /// + protected override int MaxNegativeEntries => MaxNegativeLanguages; + /// /// Gets the cache item expiration policy. /// diff --git a/tests/Polhem.Api.Client.UnitTests/Definitions/FormDefinitionLoaderFetchTests.cs b/tests/Polhem.Api.Client.UnitTests/Definitions/FormDefinitionLoaderFetchTests.cs new file mode 100644 index 00000000..d6248299 --- /dev/null +++ b/tests/Polhem.Api.Client.UnitTests/Definitions/FormDefinitionLoaderFetchTests.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +using Polhem.Api.Client.Connectors; +using Polhem.Api.Client.Definitions; +using Polhem.Core.Data; +using Polhem.Definition; +using Polhem.Definition.Forms; +using Polhem.Definition.Language; +using Polhem.JsonRpc; + +namespace Polhem.Api.Client.UnitTests.Definitions +{ + /// + /// How fetches a form's language resources: at once over a remote client, where + /// each fetch is a round trip, and one after another in process, where the calls share the caller's services. + /// + public class FormDefinitionLoaderFetchTests + { + private const string ProgId = "Employee"; + + private sealed class UnusedTransport : IJsonRpcTransport + { + public Task SendAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("The connector answers every call itself."); + + public Task> SendBatchAsync(IReadOnlyList requests, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException("The connector answers every call itself."); + } + + /// Answers each language fetch after a short delay and records how many were in flight at once. + private sealed class CountingConnector(bool isLocal) : SystemApiConnector( + PolhemApiClient.CreateWithTransport(_ => new UnusedTransport(), isLocal, TestClients.PayloadOptions)) + { + private int _inFlight; + + public int MaxInFlight { get; private set; } + + public override Task GetDefineAsync(DefineType defineType, string[]? keys = null, CancellationToken cancellationToken = default) + { + if (defineType == DefineType.FormSchema) + { + var schema = new FormSchema(ProgId, "Employee"); + var table = schema.Tables!.Add(ProgId, "Employee"); + table.Fields!.Add(new FormField("gender", "Gender", FieldDbType.String) { LangEnumName = "Common.Gender" }); + return Task.FromResult((T)(object)schema); + } + return CountAsync(() => (T)(object)new LanguageResource()); + } + + public override Task GetCustomizeLanguageAsync(string lang, string ns, CancellationToken cancellationToken = default) + => CountAsync(() => null); + + private async Task CountAsync(Func result) + { + int now = Interlocked.Increment(ref _inFlight); + lock (this) { MaxInFlight = Math.Max(MaxInFlight, now); } + await Task.Delay(20).ConfigureAwait(false); + Interlocked.Decrement(ref _inFlight); + return result(); + } + } + + [Fact] + [DisplayName("Over a remote client, the language resources of a form are fetched at once")] + public async Task GetLocalizedSchemaAsync_RemoteClient_FetchesConcurrently() + { + var connector = new CountingConnector(isLocal: false); + + await new FormDefinitionLoader(new ClientDefineAccess(connector)).GetLocalizedSchemaAsync(ProgId, "zh-TW"); + + Assert.True(connector.MaxInFlight > 1, $"At most {connector.MaxInFlight} fetch was in flight."); + } + + [Fact] + [DisplayName("Over an in-process client, the language resources of a form are fetched one after another")] + public async Task GetLocalizedSchemaAsync_InProcessClient_FetchesOneAtATime() + { + var connector = new CountingConnector(isLocal: true); + + await new FormDefinitionLoader(new ClientDefineAccess(connector)).GetLocalizedSchemaAsync(ProgId, "zh-TW"); + + Assert.Equal(1, connector.MaxInFlight); + } + } +} diff --git a/tests/Polhem.ObjectCaching.UnitTests/Database/NegativeCacheBoundTests.cs b/tests/Polhem.ObjectCaching.UnitTests/Database/NegativeCacheBoundTests.cs index 28aba242..0887df7b 100644 --- a/tests/Polhem.ObjectCaching.UnitTests/Database/NegativeCacheBoundTests.cs +++ b/tests/Polhem.ObjectCaching.UnitTests/Database/NegativeCacheBoundTests.cs @@ -4,13 +4,16 @@ using Polhem.Definition.Logging; using Polhem.Definition.Organization; using Polhem.Definition.Security; +using Polhem.Definition.Storage; using Polhem.ObjectCaching.Database; +using Polhem.ObjectCaching.Define; namespace Polhem.ObjectCaching.UnitTests.Database { /// - /// Tests the negative caching of the two caches whose keys come from the caller: unknown access tokens - /// () and unknown API key identifiers (). A miss is + /// Tests the negative caching of the caches whose keys come from the caller: unknown access tokens + /// (), unknown API key identifiers () and missing language + /// resources (). A miss is /// remembered, but in a set capped per cache, so a stream of distinct unknown keys cannot grow memory without bound. /// public class NegativeCacheBoundTests @@ -104,5 +107,25 @@ public void ApiKeyCache_ManyUnknownIds_MarkersAreCapped() Assert.Equal(ApiKeyCache.MaxNegativeIds, cache.BoundedNegativeCount); } + + [Fact] + [DisplayName("Miss markers for missing language resources are capped at MaxNegativeLanguages")] + public void LanguageResourceCache_ManyMissingResources_MarkersAreCapped() + { + var root = Directory.CreateTempSubdirectory("polhem-lang-").FullName; + try + { + var cache = new LanguageResourceCache(new FileDefineStorage(new PathOptions { DefinePath = root }), NewPrefix()); + + for (int i = 0; i < LanguageResourceCache.MaxNegativeLanguages + 500; i++) + Assert.Null(cache.Get("en", $"probe{i}")); + + Assert.Equal(LanguageResourceCache.MaxNegativeLanguages, cache.BoundedNegativeCount); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } } From ec1858873ebdd5a8a515e4cc8f8251de48139053 Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:11:49 +0800 Subject: [PATCH 2/6] Register test databases on a copy of the settings instead of the cached instance --- .../PolhemTestFixtureBuilder.cs | 4 +- .../SharedDatabaseState.Registration.cs | 50 +++++++++++-------- .../SharedDatabaseState.cs | 24 ++++----- .../TestProcessBootstrap.cs | 2 +- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/tests/Polhem.Tests.Shared/PolhemTestFixtureBuilder.cs b/tests/Polhem.Tests.Shared/PolhemTestFixtureBuilder.cs index 7940f8a3..de7863f4 100644 --- a/tests/Polhem.Tests.Shared/PolhemTestFixtureBuilder.cs +++ b/tests/Polhem.Tests.Shared/PolhemTestFixtureBuilder.cs @@ -94,7 +94,7 @@ internal ServiceProvider BuildServiceProvider(PathOptions paths) // NOTE: there is deliberately no per-fixture cache key prefix. The bootstrap and the fixture have // different `ICacheContainer` instances but share the underlying `CacheInfo.Provider`, and a prefix - // would hide the bootstrap's changes to `DatabaseSettings.Items` from the fixture's cache. + // would hide the `DatabaseSettings` the bootstrap published (with the test databases) from the fixture's cache. // Sessions stay isolated because access tokens are random `Guid` values. var provider = services.BuildServiceProvider(); @@ -107,7 +107,7 @@ internal ServiceProvider BuildServiceProvider(PathOptions paths) var defineAccess = provider.GetRequiredService(); // …though "shares the same cache" only holds until someone invalidates that slot, // so re-apply the registrations before reading them rather than assuming. - SharedDatabaseState.EnsureDatabaseSettingsApplied(defineAccess); + SharedDatabaseState.EnsureDatabaseSettingsApplied(defineAccess, paths); SharedDatabaseState.EnsureSchemaAndSeed( defineAccess, provider.GetRequiredService()); diff --git a/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs b/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs index ff8fa36c..ca62afd6 100644 --- a/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs +++ b/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs @@ -1,5 +1,6 @@ using Polhem.Core; using Polhem.Db.Manager; +using Polhem.Definition; using Polhem.Db.Providers.MySql; using Polhem.Db.Providers.Oracle; using Polhem.Db.Providers.PostgreSql; @@ -8,6 +9,7 @@ using Polhem.Definition.Database; using Polhem.Definition.Settings; using Polhem.Definition.Storage; +using Polhem.ObjectCaching.Define; using Microsoft.Data.Sqlite; namespace Polhem.Tests.Shared @@ -54,9 +56,9 @@ private static string ResolveDbName(DatabaseType dbType, string categoryId) private static void EnsureFallbackCommonDatabaseItem(IDefineAccess bootstrapAccess) { - if (bootstrapAccess.GetDatabaseSettings().Items!.Contains("common")) return; - AddDatabaseItemIfMissing( - bootstrapAccess, id: "common", categoryId: "common", + if (bootstrapAccess.GetDatabaseSettings().Items!.Contains("common") + || s_registeredItems.Exists(item => item.Id == "common")) return; + AddDatabaseItem(id: "common", categoryId: "common", dbType: DatabaseType.SQLServer, serverId: string.Empty, dbName: "common"); } @@ -72,9 +74,7 @@ private static void RegisterSqlServer(IDefineAccess bootstrapAccess, List categoryIds) { var serverId = TestDbConventions.GetServerId(dbType); - AddDatabaseServerIfMissing(bootstrapAccess, serverId, dbType, connStr); + AddDatabaseServer(serverId, dbType, connStr); foreach (var categoryId in categoryIds) { var id = TestDbConventions.GetDatabaseId(dbType, categoryId); - AddDatabaseItemIfMissing(bootstrapAccess, id, categoryId, dbType, serverId, ResolveDbName(dbType, categoryId)); + AddDatabaseItem(id, categoryId, dbType, serverId, ResolveDbName(dbType, categoryId)); } } - private static void AddDatabaseServerIfMissing(IDefineAccess bootstrapAccess, string serverId, DatabaseType dbType, string connStr) - { - s_registeredServers.Add(new RegisteredServer(serverId, dbType, connStr)); - var dbSettings = bootstrapAccess.GetDatabaseSettings(); - if (dbSettings.Servers!.Contains(serverId)) return; - dbSettings.Servers.Add(NewServer(s_registeredServers[^1])); - } + // Records the entry only; ApplyRegistrations publishes every recorded entry at once. + private static void AddDatabaseServer(string serverId, DatabaseType dbType, string connStr) + => s_registeredServers.Add(new RegisteredServer(serverId, dbType, connStr)); private static DatabaseServer NewServer(RegisteredServer server) => new() { @@ -179,12 +175,26 @@ private static void AddDatabaseServerIfMissing(IDefineAccess bootstrapAccess, st ConnectionString = server.ConnectionString }; - private static void AddDatabaseItemIfMissing(IDefineAccess bootstrapAccess, string id, string categoryId, DatabaseType dbType, string serverId, string dbName) + // Records the entry only; ApplyRegistrations publishes every recorded entry at once. + private static void AddDatabaseItem(string id, string categoryId, DatabaseType dbType, string serverId, string dbName) + => s_registeredItems.Add(new RegisteredItem(id, categoryId, dbType, serverId, dbName)); + + // WARNING: the DatabaseSettings instance in the cache is shared by every test running in the process, and + // other test classes read it while this runs. It is never changed in place: the entries are added to a copy, + // and the copy replaces the cached instance in one step, so a reader sees either the old instance or the + // complete new one. The cache slot is keyed by type name alone, so a DatabaseSettingsCache built over any + // PathOptions replaces the slot every define access reads. + private static void ApplyRegistrations(IDefineAccess access, PathOptions paths) { - s_registeredItems.Add(new RegisteredItem(id, categoryId, dbType, serverId, dbName)); - var dbSettings = bootstrapAccess.GetDatabaseSettings(); - if (dbSettings.Items!.Contains(id)) return; - dbSettings.Items.Add(NewItem(s_registeredItems[^1])); + var current = access.GetDatabaseSettings(); + var servers = s_registeredServers.Where(s => !current.Servers!.Contains(s.Id)).ToList(); + var items = s_registeredItems.Where(i => !current.Items!.Contains(i.Id)).ToList(); + if (servers.Count == 0 && items.Count == 0) return; + + var copy = current.Clone(); + foreach (var server in servers) copy.Servers!.Add(NewServer(server)); + foreach (var item in items) copy.Items!.Add(NewItem(item)); + new DatabaseSettingsCache(paths).Set(copy); } // A fresh instance per application: the same entry is written to more than one diff --git a/tests/Polhem.Tests.Shared/SharedDatabaseState.cs b/tests/Polhem.Tests.Shared/SharedDatabaseState.cs index cd14c2b1..ff53b3f4 100644 --- a/tests/Polhem.Tests.Shared/SharedDatabaseState.cs +++ b/tests/Polhem.Tests.Shared/SharedDatabaseState.cs @@ -1,4 +1,5 @@ using Polhem.Db.Manager; +using Polhem.Definition; using Polhem.Definition.Database; using Polhem.Definition.Settings; using Polhem.Definition.Storage; @@ -48,12 +49,14 @@ public static partial class SharedDatabaseState /// whose connection string env var is set. Idempotent across the process. /// /// - /// A CacheDefineAccess backed by the same CacheContainer the rest - /// of the framework will read from; new items are added to its DatabaseSettings. + /// A CacheDefineAccess backed by the same cache the rest of the framework will read from; the + /// DatabaseSettings it caches is replaced by a copy that holds the new entries. /// - public static void EnsureRegistered(IDefineAccess bootstrapAccess) + /// The path options of . + public static void EnsureRegistered(IDefineAccess bootstrapAccess, PathOptions paths) { ArgumentNullException.ThrowIfNull(bootstrapAccess); + ArgumentNullException.ThrowIfNull(paths); lock (s_registerLock) { if (s_registered) return; @@ -65,6 +68,7 @@ public static void EnsureRegistered(IDefineAccess bootstrapAccess) RegisterMySql(bootstrapAccess, categoryIds); RegisterOracle(bootstrapAccess, categoryIds); EnsureFallbackCommonDatabaseItem(bootstrapAccess); + ApplyRegistrations(bootstrapAccess, paths); s_registered = true; } @@ -83,20 +87,14 @@ public static void EnsureRegistered(IDefineAccess bootstrapAccess) /// of trusting that nobody else has touched the slot. /// /// The define access whose DatabaseSettings to top up. - public static void EnsureDatabaseSettingsApplied(IDefineAccess access) + /// The path options of . + public static void EnsureDatabaseSettingsApplied(IDefineAccess access, PathOptions paths) { ArgumentNullException.ThrowIfNull(access); + ArgumentNullException.ThrowIfNull(paths); lock (s_registerLock) { - var dbSettings = access.GetDatabaseSettings(); - foreach (var server in s_registeredServers) - { - if (!dbSettings.Servers!.Contains(server.Id)) dbSettings.Servers.Add(NewServer(server)); - } - foreach (var item in s_registeredItems) - { - if (!dbSettings.Items!.Contains(item.Id)) dbSettings.Items.Add(NewItem(item)); - } + ApplyRegistrations(access, paths); } } diff --git a/tests/Polhem.Tests.Shared/TestProcessBootstrap.cs b/tests/Polhem.Tests.Shared/TestProcessBootstrap.cs index 17a4fa5f..265bb5ee 100644 --- a/tests/Polhem.Tests.Shared/TestProcessBootstrap.cs +++ b/tests/Polhem.Tests.Shared/TestProcessBootstrap.cs @@ -133,7 +133,7 @@ private static void InitializeOnce() var bootstrapStorage = new FileDefineStorage(pathOptions); var bootstrapAccess = new CacheDefineAccess(bootstrapStorage, pathOptions); - SharedDatabaseState.EnsureRegistered(bootstrapAccess); + SharedDatabaseState.EnsureRegistered(bootstrapAccess, pathOptions); // Boot-time settings are read through `SystemSettingsLoader`, which does not depend on `IDefineAccess`. // `tests/Define/SystemSettings.xml` points `MasterKeySource` at the `POLHEM_MASTER_KEY` environment From d7c19f31c468d6c350793dbd838c6641ec2f13ba Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:11:49 +0800 Subject: [PATCH 3/6] Report a permission model id that differs from the registry only in case --- .../Permission/PermissionBindingValidator.cs | 21 +++++++++++++++---- .../Settings/PermissionBindingTests.cs | 12 +++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Polhem.Definition/Settings/Permission/PermissionBindingValidator.cs b/src/Polhem.Definition/Settings/Permission/PermissionBindingValidator.cs index 79c5e296..bb9eb63d 100644 --- a/src/Polhem.Definition/Settings/Permission/PermissionBindingValidator.cs +++ b/src/Polhem.Definition/Settings/Permission/PermissionBindingValidator.cs @@ -55,22 +55,35 @@ private static IEnumerable ValidateSensitiveCategories(FormSchema schema { if (field.SensitiveCategory == SensitiveCategory.None) { continue; } string modelId = field.SensitiveCategory.ToPermissionModelId(); - if (models.Models == null || !models.Models.Contains(modelId)) + string? registered = FindModelId(models, modelId); + if (registered == null) yield return $"Form '{schema.ProgId}': field '{table.TableName}.{field.FieldName}' has SensitiveCategory '{field.SensitiveCategory}' but its well-known model '{modelId}' does not exist in the permission registry."; + else if (!string.Equals(registered, modelId, StringComparison.Ordinal)) + yield return $"Form '{schema.ProgId}': field '{table.TableName}.{field.FieldName}' has SensitiveCategory '{field.SensitiveCategory}', whose model '{modelId}' is registered as '{registered}'. Model ids are compared exactly at run time, so the case must match."; } } } - // PermissionModelId must reference an existing model (when declared). + // PermissionModelId must reference an existing model (when declared), spelled exactly as the registry spells it. private static IEnumerable ValidateModelIdReference(FormSchema schema, PermissionModels models) { - if (!string.IsNullOrEmpty(schema.PermissionModelId) - && (models.Models == null || !models.Models.Contains(schema.PermissionModelId))) + if (string.IsNullOrEmpty(schema.PermissionModelId)) { yield break; } + string? registered = FindModelId(models, schema.PermissionModelId); + if (registered == null) { yield return $"Form '{schema.ProgId}': PermissionModelId '{schema.PermissionModelId}' does not exist in the permission registry."; } + else if (!string.Equals(registered, schema.PermissionModelId, StringComparison.Ordinal)) + { + yield return $"Form '{schema.ProgId}': PermissionModelId '{schema.PermissionModelId}' is registered as '{registered}'. Model ids are compared exactly at run time (the role grants and the capabilities), so the case must match."; + } } + // The registry's collection finds a model whatever the case of the id, but the run-time checks compare model + // ids exactly; this returns the registry's own spelling so the two can be told apart. + private static string? FindModelId(PermissionModels models, string modelId) + => models.Models != null && models.Models.Contains(modelId) ? models.Models[modelId].ModelId : null; + // Record scope is master-only: a detail (non-master) table must not mark any scope role. // Such a column would be silently ignored by the resolver, so flag it as a configuration // error at load time rather than letting it mislead. diff --git a/tests/Polhem.Definition.UnitTests/Settings/PermissionBindingTests.cs b/tests/Polhem.Definition.UnitTests/Settings/PermissionBindingTests.cs index 60674358..2453fb2b 100644 --- a/tests/Polhem.Definition.UnitTests/Settings/PermissionBindingTests.cs +++ b/tests/Polhem.Definition.UnitTests/Settings/PermissionBindingTests.cs @@ -112,6 +112,18 @@ public void Validate_ValidBinding_ReturnsEmpty() Assert.Empty(errors); } + [Fact] + [DisplayName("Validator reports a PermissionModelId that matches a registered model only when case is ignored")] + public void Validate_ModelIdDiffersInCase_ReturnsError() + { + var schemas = new FormSchema[] { BuildForm("purchaseorder") }; + + var errors = PermissionBindingValidator.Validate(schemas, BuildRegistry()); + + var error = Assert.Single(errors); + Assert.Contains("'purchaseorder' is registered as 'PurchaseOrder'", error); + } + [Fact] [DisplayName("Validator reports an error for a PermissionModelId that does not exist")] public void Validate_UnknownModelId_ReturnsError() From 218dc7858255ace20f72a1668d2b6f1e002e320b Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:26:54 +0800 Subject: [PATCH 4/6] Remove the unused endpoint reachability probe --- src/Polhem.Api.Client/HttpUtilities.cs | 42 ------ .../HttpUtilitiesTests.cs | 133 ------------------ 2 files changed, 175 deletions(-) diff --git a/src/Polhem.Api.Client/HttpUtilities.cs b/src/Polhem.Api.Client/HttpUtilities.cs index 56a71d51..bae2b9b6 100644 --- a/src/Polhem.Api.Client/HttpUtilities.cs +++ b/src/Polhem.Api.Client/HttpUtilities.cs @@ -22,11 +22,6 @@ internal static class HttpUtilities /// internal static HttpMessageHandler SharedHandler { get; } = CreateSharedHandler(); - private static readonly HttpClient s_probeClient = new(SharedHandler, disposeHandler: false) - { - Timeout = TimeSpan.FromSeconds(30) - }; - private static HttpMessageHandler CreateSharedHandler() { // `HttpClientHandler` is the runtime's default handler, which the mobile and browser runtimes route to @@ -86,42 +81,5 @@ public static bool IsUrl(string input) return Uri.TryCreate(input, UriKind.Absolute, out uriResult) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); } - - /// - /// Asynchronously probes whether the specified endpoint is reachable over the network. - /// - /// The endpoint URL to probe. - /// The probe timeout. Defaults to 5 seconds. - /// A token that cancels the probe; cancelling it throws rather than reporting unreachable. - /// - /// True if the server returns any HTTP response (including 4xx/5xx status codes); - /// false on DNS failure, connection refused, or timeout. - /// - /// - /// Uses an HTTP HEAD request as a low-cost probe. Any HTTP response — including 404 or 405 — - /// proves the host is alive and routing requests, so it is treated as "reachable". - /// Use this only for transport-level reachability; verifying that the endpoint actually - /// implements the expected service contract is the caller's responsibility. - /// - public static async Task IsEndpointReachableAsync(string endpoint, TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(timeout ?? TimeSpan.FromSeconds(5)); - try - { - using var request = new HttpRequestMessage(HttpMethod.Head, endpoint); - using var response = await s_probeClient.SendAsync(request, cts.Token).ConfigureAwait(false); - return true; - } - catch (HttpRequestException) - { - return false; - } - catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return false; - } - } } } diff --git a/tests/Polhem.Api.Client.UnitTests/HttpUtilitiesTests.cs b/tests/Polhem.Api.Client.UnitTests/HttpUtilitiesTests.cs index 6f0240fd..8b2fb3f3 100644 --- a/tests/Polhem.Api.Client.UnitTests/HttpUtilitiesTests.cs +++ b/tests/Polhem.Api.Client.UnitTests/HttpUtilitiesTests.cs @@ -1,6 +1,4 @@ using System.ComponentModel; -using System.Net; -using System.Net.Sockets; namespace Polhem.Api.Client.UnitTests { @@ -37,136 +35,5 @@ public void UsesPlatformDefaultHandler_DesktopHost_ReturnsFalse() { Assert.False(HttpUtilities.UsesPlatformDefaultHandler()); } - - [Fact] - [DisplayName("IsEndpointReachableAsync returns true for a running endpoint")] - public async Task IsEndpointReachableAsync_RunningServer_ReturnsTrue() - { - await using var server = await LoopbackHttpServer.StartAsync(); - - bool result = await HttpUtilities.IsEndpointReachableAsync(server.BuildUrl("/probe")); - - Assert.True(result); - } - - [Fact] - [DisplayName("IsEndpointReachableAsync treats a 4xx response as reachable and returns true")] - public async Task IsEndpointReachableAsync_NotFoundStatus_ReturnsTrue() - { - await using var server = await LoopbackHttpServer.StartAsync(statusLine: "HTTP/1.1 404 Not Found", body: string.Empty); - - bool result = await HttpUtilities.IsEndpointReachableAsync(server.BuildUrl("/missing")); - - Assert.True(result); - } - - [Fact] - [DisplayName("IsEndpointReachableAsync returns false when the connection is refused")] - public async Task IsEndpointReachableAsync_ConnectionRefused_ReturnsFalse() - { - // Port 1 on 127.0.0.1 is reserved and nothing listens on it locally, so the connection is refused. - bool result = await HttpUtilities.IsEndpointReachableAsync("http://127.0.0.1:1/probe"); - - Assert.False(result); - } - - [Fact] - [DisplayName("IsEndpointReachableAsync returns false when the probe times out")] - public async Task IsEndpointReachableAsync_Timeout_ReturnsFalse() - { - await using var stall = await StallingServer.StartAsync(); - - bool result = await HttpUtilities.IsEndpointReachableAsync( - stall.BuildUrl("/never"), - timeout: TimeSpan.FromMilliseconds(200)); - - Assert.False(result); - } - - /// - /// Loopback TCP listener that accepts connections but never reads or writes, - /// used to deterministically trigger client-side timeouts. - /// - private sealed class StallingServer : IAsyncDisposable - { - private readonly TcpListener _listener; - private readonly CancellationTokenSource _cts = new(); - private readonly Task _acceptLoop; - private readonly List _accepted = []; - - public int Port { get; } - - private StallingServer(TcpListener listener, int port) - { - _listener = listener; - Port = port; - _acceptLoop = Task.Run(AcceptLoopAsync); - } - - private async Task AcceptLoopAsync() - { - try - { - while (!_cts.IsCancellationRequested) - { - TcpClient client; - try - { - client = await _listener.AcceptTcpClientAsync(_cts.Token); - } - catch (OperationCanceledException) - { - break; - } - - lock (_accepted) - { - _accepted.Add(client); - } - // Intentionally never read or respond — the connection stalls until disposal. - } - } - catch (ObjectDisposedException) - { - // Listener was stopped concurrently. - } - catch (SocketException) - { - // Listener was stopped while accepting. - } - } - - public static Task StartAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - return Task.FromResult(new StallingServer(listener, port)); - } - - public string BuildUrl(string path) => $"http://127.0.0.1:{Port}{path}"; - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - _listener.Stop(); - try - { - await _acceptLoop; - } - catch (OperationCanceledException) - { - // Expected during cancellation. - } - lock (_accepted) - { - foreach (var client in _accepted) - { - try { client.Dispose(); } catch (ObjectDisposedException) { } - } - } - _cts.Dispose(); - } - } } } From de18b464181649eca4653e23344ae6175fd6b86b Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:26:54 +0800 Subject: [PATCH 5/6] Answer an unreadable filter value with InvalidParams A filter value that cannot be read as the type of its field now throws InvalidRequestValueException, which the error contract sends as -32602 with its message, instead of an InvalidOperationException that answered -32099 with a fixed message. Public API: adds Polhem.Core.Exceptions.InvalidRequestValueException. Additive only; the conversion it replaces has not shipped in a release. --- CHANGELOG.md | 2 +- CHANGELOG.zh-TW.md | 2 +- docs/en/api/jsonrpc-frontend-integration.md | 6 ++-- docs/en/changelogs/1.13.0.md | 5 ++-- .../zh-TW/api/jsonrpc-frontend-integration.md | 8 ++--- docs/zh-TW/changelogs/1.13.0.md | 5 ++-- .../JsonRpc/JsonRpcErrorContract.cs | 4 +++ .../InvalidRequestValueException.cs | 29 +++++++++++++++++++ src/Polhem.Core/PublicAPI.Unshipped.txt | 3 ++ src/Polhem.Db/Dml/FilterValueCoercion.cs | 5 ++-- .../Dispatch/ExceptionMappingTests.cs | 12 ++++++++ .../Form/GetListFilterValueDbTests.cs | 18 ++++++++++++ .../Dml/FilterValueCoercionTests.cs | 5 ++-- 13 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 src/Polhem.Core/Exceptions/InvalidRequestValueException.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e22a9d..c7d80672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ Details and migration steps: [docs/en/changelogs/1.13.0.md](docs/en/changelogs/1 - A table with a Binary column reads back over the JSON body codec and the Plain format again, so such a form can be created and saved from a JSON client. - A `DateTime` filter value, parameter value or message property arrives over the JSON body codec as the same UTC instant MessagePack delivers, instead of keeping an Unspecified or Local kind; a JSON response now marks such a value with `Z`. - A table with a read-only column and a modified row reads back over the JSON body codec and the Plain format, as it already did over MessagePack. -- A form's list or count filter converts a string value to the type of its field, so a date, a `Guid` or a quoted number sent in a Plain body filters a typed column on PostgreSQL and Oracle, and no longer returns no rows on SQL Server; text a database used to convert on its own, such as `""` for a number or `1` for a boolean, is now refused. +- A form's list or count filter converts a string value to the type of its field, so a date, a `Guid` or a quoted number sent in a Plain body filters a typed column on PostgreSQL and Oracle, and no longer returns no rows on SQL Server; text a database used to convert on its own, such as `""` for a number or `1` for a boolean, is now refused with `-32602 InvalidParams` and a message naming the field. - An Avalonia editor bound to a detail row no longer throws when the form replaces its dataset, for example on New; it is left alone, as in Blazor. The rules both heads share for this, for time write-back, lookup display fields, menu rows, check-box cells and sort marks now live once in `Polhem.UI.Core`. - A MudBlazor or Fluent UI grid releases its data object subscriptions when it is disposed; before, the data object kept every grid a page had shown. The rules both Blazor UI packages repeated (the column filter, the menu drawer, card clicks, rejected numeric and time entries, the grid's editor script and the dialog parameters) now live in `Polhem.Web.Blazor`, and the column filter has behavior tests. diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md index bcb98624..19fbe3e7 100644 --- a/CHANGELOG.zh-TW.md +++ b/CHANGELOG.zh-TW.md @@ -33,7 +33,7 @@ Polhem 套件的重要變更,每個版本只列幾項重點。每個版本都 - 含 Binary 欄位的資料表又能經由 JSON body codec 與 Plain 格式讀回,因此 JSON 用戶端可以新增並儲存這類表單。 - `DateTime` 型別的篩選值、參數值與訊息屬性經由 JSON body codec 傳遞時,會與 MessagePack 一樣以同一個 UTC 時間點送達,不再保留 Unspecified 或 Local 的 kind;JSON 回應中這類值現在帶有 `Z`。 - 含唯讀欄位且有修改過的列的資料表,可經由 JSON body codec 與 Plain 格式讀回,與 MessagePack 原本的行為相同。 -- 表單清單與筆數查詢的過濾條件會把字串值轉成其欄位的型別,因此 Plain body 送出的日期、`Guid` 或加了引號的數字,可以在 PostgreSQL 與 Oracle 上過濾有型別的欄位,在 SQL Server 上也不再查無資料;資料庫過去會自行轉換的文字(例如數字欄位的 `""`、布林欄位的 `1`)現在會被拒絕。 +- 表單清單與筆數查詢的過濾條件會把字串值轉成其欄位的型別,因此 Plain body 送出的日期、`Guid` 或加了引號的數字,可以在 PostgreSQL 與 Oracle 上過濾有型別的欄位,在 SQL Server 上也不再查無資料;資料庫過去會自行轉換的文字(例如數字欄位的 `""`、布林欄位的 `1`)現在會以 `-32602 InvalidParams` 拒絕,訊息會指出欄位。 - Avalonia 綁在明細列上的編輯器,在表單替換資料集時(例如新增)不再擲出例外,改為與 Blazor 一樣保持不動。兩個 head 共用的這項規則,以及時間欄寫回、lookup 顯示欄位、選單列、勾選欄與排序標記的規則,現在只在 `Polhem.UI.Core` 寫一份。 - MudBlazor 與 Fluent UI 的 grid 在釋放時會取消對 data object 的訂閱;原本 data object 會一直留著頁面顯示過的每個 grid。兩個 Blazor UI 套件重複的規則(欄位篩選、選單抽屜、卡片點擊、被拒的數值與時間輸入、grid 的編輯器腳本、對話框參數)現在放在 `Polhem.Web.Blazor`,欄位篩選也有了行為測試。 diff --git a/docs/en/api/jsonrpc-frontend-integration.md b/docs/en/api/jsonrpc-frontend-integration.md index a7b8981d..762158f4 100644 --- a/docs/en/api/jsonrpc-frontend-integration.md +++ b/docs/en/api/jsonrpc-frontend-integration.md @@ -154,8 +154,8 @@ filters a typed column on every database (`GetListFilterValueDbTests` runs this `StartsWith` and `EndsWith` operators, since they match text. A relation field converts by the `DbType` it declares, so declare the type of the column it reads. -A string that is not valid for its field's type is refused, including text a database used to -convert on its own, such as `""` for a number or `1` for a boolean. `parameters` entries and other `object` +A string that is not valid for its field's type is refused with `-32602 InvalidParams` and a message naming the +field, including text a database used to convert on its own, such as `""` for a number or `1` for a boolean. `parameters` entries and other `object` members are not converted. A client that needs typed values there uses the JSON body codec (`"codec": "json"` with `Encoded` or `Encrypted`), whose `[code, value]` envelope carries the type — that envelope is what `WireValueEnvelope` in `wire-contracts/messages.d.ts` describes; a Plain body @@ -290,7 +290,7 @@ larger than it accepts. Up to 1.1.0 a response also echoed the method name in a | `-32700` | `ParseError` | Malformed JSON in the request body | Fix client serialization | | `-32600` | `InvalidRequest` | Not a valid JSON-RPC request (no `method`, wrong `jsonrpc`), a rejected API key, or a malformed `Authorization` header | Inspect headers and body | | `-32601` | `MethodNotFound` | The method name is not `progId.action`, or names no action the business object exposes; the message is fixed and does not repeat the name | Check method name / casing | -| `-32602` | `InvalidParams` | The parameters carry no value to bind (no `params`, no `value`, or a `null` one); a `Plain` body could not be read into the type the method takes; or, while the host requires the wire frame, a signed-in session called a [replay-protected](api-method-reference.md#replay-protection) method with a `Plain` or `Encoded` payload. The message is always "Invalid params.". Arguments that are readable but invalid answer `-32099` | Fix the `params` shape, or call that method `Encrypted` | +| `-32602` | `InvalidParams` | The parameters carry no value to bind (no `params`, no `value`, or a `null` one); a `Plain` body could not be read into the type the method takes; or, while the host requires the wire frame, a signed-in session called a [replay-protected](api-method-reference.md#replay-protection) method with a `Plain` or `Encoded` payload; the message of these is always "Invalid params.". A filter value that cannot be read as the type of its field answers this code too, with a message naming the field. Other arguments that are readable but invalid answer `-32099` | Fix the `params` shape, or call that method `Encrypted` | | `-32603` | `InternalError` | Unhandled server-side exception (`-32000` up to 1.1.0). An `Encrypted` payload that fails authentication lands here too, so a client whose payload version does not match the server's sees it on every `Encrypted` call | Not user-facing. The message is "Internal server error" unless the server runs in debug mode. When every `Encrypted` call fails this way and other formats work, check that the client and the server are on matching versions | | `-32001` | `Unauthorized` | The method needs a signed-in caller and the access token is missing, unknown or expired | Sign in again | | `-32002` | `CompanyNotEntered` | Method needs company context | Call `System.EnterCompany` first | diff --git a/docs/en/changelogs/1.13.0.md b/docs/en/changelogs/1.13.0.md index 3232fc27..ed55e270 100644 --- a/docs/en/changelogs/1.13.0.md +++ b/docs/en/changelogs/1.13.0.md @@ -74,8 +74,9 @@ count and delete now convert a filter's string values to the type of the field t query is built: a `DateTime` field reads a UTC instant (text without an offset is taken to be UTC), a `Date` field reads the date written, and `Guid`, numeric and `Boolean` fields read their invariant text; dates are read in their ISO 8601 forms only. `String`, `Text` and `Time` fields and the `LIKE` operators keep the string. A string that is not -valid for its field's type is refused before the query reaches the database, including text a database used to -convert on its own, such as `""` for a number or `1` for a boolean. +valid for its field's type is refused before the query reaches the database with `-32602 InvalidParams` and a +message naming the field (an in-process caller catches `InvalidRequestValueException`, an `ArgumentException`), +including text a database used to convert on its own, such as `""` for a number or `1` for a boolean. The conversion applies to a string value on any codec; values of any other type are unchanged. A relation field converts by the `DbType` it declares. See diff --git a/docs/zh-TW/api/jsonrpc-frontend-integration.md b/docs/zh-TW/api/jsonrpc-frontend-integration.md index 741581cb..f615bf09 100644 --- a/docs/zh-TW/api/jsonrpc-frontend-integration.md +++ b/docs/zh-TW/api/jsonrpc-frontend-integration.md @@ -1,4 +1,4 @@ - + # JSON-RPC 前端整合指引 [English](../../en/api/jsonrpc-frontend-integration.md) · [← 文件索引](../README.md) @@ -143,8 +143,8 @@ Encoded / Encrypted 的 body 是不透明的位元組。即使在那條路徑上 - `String`、`Text` 與 `Time` 欄位保留字串,`Like`、`Contains`、`StartsWith` 與 `EndsWith` 運算子也是, 因為它們比對的是文字。 -relation 欄位依其宣告的 `DbType` 轉換,因此請宣告它所讀取欄位的型別。不符合欄位型別的字串會被拒絕,包括資料庫 -過去會自行轉換的文字,例如數字欄位的 `""`、布林欄位的 `1`。`parameters` 的項目與其他 `object` 成員不會轉換;需要在那裡帶型別的值時, +relation 欄位依其宣告的 `DbType` 轉換,因此請宣告它所讀取欄位的型別。不符合欄位型別的字串會以 `-32602 InvalidParams` +拒絕,訊息會指出欄位,包括資料庫過去會自行轉換的文字,例如數字欄位的 `""`、布林欄位的 `1`。`parameters` 的項目與其他 `object` 成員不會轉換;需要在那裡帶型別的值時, client 改用 JSON body codec(`"codec": "json"` 搭配 `Encoded` 或 `Encrypted`),它的 `[code, value]` 信封會帶上型別 —— `wire-contracts/messages.d.ts` 裡的 `WireValueEnvelope` 描述的正是這個信封;Plain body 帶的則是裸值。 @@ -262,7 +262,7 @@ XML 相同。定義在每條路徑上都以 XML 傳輸,因為它們的巢狀 | `-32700` | `ParseError` | request body 不是合法 JSON | 修 client 序列化 | | `-32600` | `InvalidRequest` | 不是有效的 JSON-RPC 請求(缺 `method`、`jsonrpc` 不對)、金鑰被拒,或 `Authorization` header 格式錯誤 | 檢查 headers 與 body | | `-32601` | `MethodNotFound` | 方法名稱不是 `progId.action` 格式,或沒有對應業務物件公開的 action;訊息固定,不會回帶名稱 | 檢查方法名稱 / 大小寫 | -| `-32602` | `InvalidParams` | 參數沒有可繫結的值(沒有 `params`、沒有 `value`,或 `value` 為 `null`);`Plain` 本文無法讀成方法所接受的型別;或 host 要求 wire frame 時,已登入 session 以 `Plain` 或 `Encoded` payload 呼叫[有重放防護](api-method-reference.md#重放防護)的方法。訊息固定為「Invalid params.」。讀得進來但內容不合法的參數回 `-32099` | 修正 `params` 的結構,或改以 `Encrypted` 呼叫該方法 | +| `-32602` | `InvalidParams` | 參數沒有可繫結的值(沒有 `params`、沒有 `value`,或 `value` 為 `null`);`Plain` 本文無法讀成方法所接受的型別;或 host 要求 wire frame 時,已登入 session 以 `Plain` 或 `Encoded` payload 呼叫[有重放防護](api-method-reference.md#重放防護)的方法;這幾種的訊息固定為「Invalid params.」。過濾值無法讀成其欄位的型別時也回這個代碼,訊息會指出欄位。其他讀得進來但內容不合法的參數回 `-32099` | 修正 `params` 的結構,或改以 `Encrypted` 呼叫該方法 | | `-32603` | `InternalError` | 未處理的 server 端例外(1.1.0 以前為 `-32000`)。驗證失敗的 `Encrypted` payload 也會落在這裡,所以 payload 版本與伺服端不相符的用戶端,每個 `Encrypted` 呼叫都會收到它 | 訊息不適合對使用者顯示。除非伺服端在 debug 模式,訊息一律為「Internal server error」。若所有 `Encrypted` 呼叫都這樣失敗、其他格式正常,請確認用戶端與伺服端的版本相符 | | `-32001` | `Unauthorized` | 方法需要已登入的呼叫者,而 access token 缺漏、未知或已過期 | 重新登入 | | `-32002` | `CompanyNotEntered` | 方法需要公司 context | 先呼叫 `System.EnterCompany` | diff --git a/docs/zh-TW/changelogs/1.13.0.md b/docs/zh-TW/changelogs/1.13.0.md index 26961f0d..0071da48 100644 --- a/docs/zh-TW/changelogs/1.13.0.md +++ b/docs/zh-TW/changelogs/1.13.0.md @@ -1,4 +1,4 @@ - + # Polhem 1.13.0 — 發佈說明 [English](../../en/changelogs/1.13.0.md) @@ -54,7 +54,8 @@ PostgreSQL 拒絕拿它與有型別的欄位比較(`operator does not exist: d 則把加了引號的小數轉成欄位本身的精度,`'10.5'` 對整數精度的欄位會以 11 比較,不報錯就查無資料。表單的清單、筆數與刪除,現在會在組查詢之前,把過濾條件中的字串值轉成所比對欄位的型別: `DateTime` 欄位讀成 UTC 時間點(沒帶時差的文字視為 UTC),`Date` 欄位讀取寫出的日期,`Guid`、數值與 `Boolean` 欄位讀取與文化無關的文字形式;日期只接受 ISO 8601 形式。`String`、`Text` 與 `Time` 欄位以及 `LIKE` 系列運算子 -保留字串。不符合欄位型別的字串,會在查詢送到資料庫之前被拒絕,包括資料庫過去會自行轉換的文字,例如數字欄位的 +保留字串。不符合欄位型別的字串,會在查詢送到資料庫之前以 `-32602 InvalidParams` 拒絕,訊息會指出欄位(同一行程內的呼叫端 +捕捉的是 `InvalidRequestValueException`,它是 `ArgumentException`),包括資料庫過去會自行轉換的文字,例如數字欄位的 `""`、布林欄位的 `1`。 這個轉換適用於任何 codec 送來的字串值;其他型別的值不受影響。relation 欄位依其宣告的 `DbType` 轉換。見 diff --git a/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorContract.cs b/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorContract.cs index 7aaedf90..a5d5f0f1 100644 --- a/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorContract.cs +++ b/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorContract.cs @@ -98,6 +98,10 @@ private sealed record Mapping( new(typeof(InvalidParamsException), JsonRpcErrorCode.InvalidParams, null, "Invalid params."), new(typeof(InvalidPayloadException), JsonRpcErrorCode.InvalidParams, null, "Invalid params."), + // A value the caller sent that cannot be read as its type. Its message names only what the caller sent, so + // it travels verbatim. Ahead of the ArgumentException row it derives from. + new(typeof(InvalidRequestValueException), JsonRpcErrorCode.InvalidParams, null), + // Ahead of the UnauthorizedAccessException row it derives from, which would otherwise // swallow it into UserMessage with a fixed message. new(typeof(AuthenticationRequiredException), JsonRpcErrorCode.Unauthorized, diff --git a/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs b/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs new file mode 100644 index 00000000..f5e8ed2f --- /dev/null +++ b/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs @@ -0,0 +1,29 @@ +namespace Polhem.Core.Exceptions +{ + /// + /// Thrown when a value the caller sent cannot be read as the type it stands for, such as a filter value that is not + /// a valid date for the field it is compared with. + /// + /// + /// The JSON-RPC transport sends it as InvalidParams (-32602) with its message, so throw it only with + /// text that names what the caller sent: a field name and a type, never a table, a column or a server detail. It + /// derives from , so an in-process caller that catches that type still sees it. + /// + public sealed class InvalidRequestValueException : ArgumentException + { + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message naming the value that could not be read. + public InvalidRequestValueException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and a reference to the underlying cause. + /// + /// The message naming the value that could not be read. + /// The exception that caused this failure. + public InvalidRequestValueException(string message, Exception innerException) + : base(message, innerException) { } + } +} diff --git a/src/Polhem.Core/PublicAPI.Unshipped.txt b/src/Polhem.Core/PublicAPI.Unshipped.txt index e807917c..155a88fd 100644 --- a/src/Polhem.Core/PublicAPI.Unshipped.txt +++ b/src/Polhem.Core/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable Polhem.Core.Expressions.ExpressionEvaluationException.Position.get -> int? Polhem.Core.Expressions.ExpressionEvaluationException.Position.init -> void +Polhem.Core.Exceptions.InvalidRequestValueException +Polhem.Core.Exceptions.InvalidRequestValueException.InvalidRequestValueException(string! message) -> void +Polhem.Core.Exceptions.InvalidRequestValueException.InvalidRequestValueException(string! message, System.Exception! innerException) -> void diff --git a/src/Polhem.Db/Dml/FilterValueCoercion.cs b/src/Polhem.Db/Dml/FilterValueCoercion.cs index 7d1a9178..524f30f3 100644 --- a/src/Polhem.Db/Dml/FilterValueCoercion.cs +++ b/src/Polhem.Db/Dml/FilterValueCoercion.cs @@ -1,5 +1,6 @@ using System.Globalization; using Polhem.Core.Data; +using Polhem.Core.Exceptions; using Polhem.Definition.Filters; using Polhem.Definition.Forms; @@ -48,7 +49,7 @@ internal static class FilterValueCoercion /// /// The filter, with the field names the table declares. /// The table whose fields the filter names. - /// A string value cannot be read as its field's type. + /// A string value cannot be read as its field's type. public static FilterNode Coerce(FilterNode node, FormTable formTable) { if (node is FilterGroup group) @@ -105,7 +106,7 @@ FieldDbType.Decimal or FieldDbType.Currency _ => text, }; - return parsed ?? throw new InvalidOperationException( + return parsed ?? throw new InvalidRequestValueException( $"The filter value for the field '{field.FieldName}' is not a valid {field.DbType}."); } } diff --git a/tests/Polhem.Api.Server.UnitTests/Dispatch/ExceptionMappingTests.cs b/tests/Polhem.Api.Server.UnitTests/Dispatch/ExceptionMappingTests.cs index 0cd05df6..7b176913 100644 --- a/tests/Polhem.Api.Server.UnitTests/Dispatch/ExceptionMappingTests.cs +++ b/tests/Polhem.Api.Server.UnitTests/Dispatch/ExceptionMappingTests.cs @@ -40,6 +40,18 @@ public void MapCode_ForbiddenException_ReturnsPermissionDeniedCode() Assert.Equal("Permission denied: 'Delete' on model 'PurchaseOrder'.", message); } + [Fact] + [DisplayName("MapCode returns the InvalidParams code and the original message for InvalidRequestValueException, ahead of the ArgumentException row")] + public void MapCode_InvalidRequestValueException_ReturnsInvalidParamsCode() + { + var ex = new InvalidRequestValueException("The filter value for the field 'due_date' is not a valid Date."); + + var (code, message) = PolhemExceptionMapper.MapCode(ex); + + Assert.Equal(JsonRpcErrorCode.InvalidParams, code); + Assert.Equal("The filter value for the field 'due_date' is not a valid Date.", message); + } + [Fact] [DisplayName("MapCode returns the CompanyAccessDenied code and the original message for CompanyAccessDeniedException")] public void MapCode_CompanyAccessDeniedException_ReturnsCompanyAccessDeniedCode() diff --git a/tests/Polhem.Api.Server.UnitTests/Form/GetListFilterValueDbTests.cs b/tests/Polhem.Api.Server.UnitTests/Form/GetListFilterValueDbTests.cs index 6ebd3f88..ca581f62 100644 --- a/tests/Polhem.Api.Server.UnitTests/Form/GetListFilterValueDbTests.cs +++ b/tests/Polhem.Api.Server.UnitTests/Form/GetListFilterValueDbTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Data; +using Polhem.Api.Core.JsonRpc; using Polhem.Api.Core.Messages.Form; using Polhem.Api.Core.Transformers; using Polhem.Core.Data; @@ -108,6 +109,23 @@ public class GetListFilterValueDbTests : IClassFixture [DisplayName("SQL Server: a GetList over the JSON body codec filtering with In, Between, a range and NotEqual returns the matching rows")] public Task JsonCodec_SqlServer_RangeAndSetFilters_ReturnMatchingRows() => RunJsonCodecRangeAndSet(DatabaseType.SQLServer); + [DbFact(DatabaseType.SQLite)] + [DisplayName("SQLite: a Plain GetList whose filter value is not valid for its field answers InvalidParams and names the field")] + public Task Plain_Sqlite_InvalidFilterValue_ReturnsInvalidParams() => WithSeededForm(DatabaseType.SQLite, async (executor, progId) => + { + const string Body = """{"selectFields":"sys_id","filter":{"fieldName":"due_date","value":"02/01/2026"},"sortFields":[{"fieldName":"sys_id"}]}"""; + var requestJson = $$""" + {"jsonrpc":"2.0","method":"{{progId}}.{{FormActions.GetList}}","params":{"format":0,"value":{{Body}}},"id":"plain-bad"} + """; + + var response = await executor.ExecuteJsonAsync(requestJson); + + Assert.NotNull(response.Error); + Assert.Equal((int)JsonRpcErrorCode.InvalidParams, response.Error!.Code); + Assert.Contains(DueDate, response.Error.Message, StringComparison.Ordinal); + Assert.DoesNotContain("02/01/2026", response.Error.Message, StringComparison.Ordinal); + }); + private Task RunPlainRangeAndSet(DatabaseType databaseType) { return WithSeededForm(databaseType, async (executor, progId) => diff --git a/tests/Polhem.Db.UnitTests/Dml/FilterValueCoercionTests.cs b/tests/Polhem.Db.UnitTests/Dml/FilterValueCoercionTests.cs index b390391b..4745deed 100644 --- a/tests/Polhem.Db.UnitTests/Dml/FilterValueCoercionTests.cs +++ b/tests/Polhem.Db.UnitTests/Dml/FilterValueCoercionTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Polhem.Core.Data; +using Polhem.Core.Exceptions; using Polhem.Db.Dml; using Polhem.Definition; using Polhem.Definition.Database; @@ -116,7 +117,7 @@ public void Coerce_LikeOperators_KeepStrings(ComparisonOperator op) [DisplayName("A string that is not valid for its field's type is refused with the field's name")] public void Coerce_InvalidValue_Throws() { - var ex = Assert.Throws(() => CoercedValue(SysFields.RowId, "not-a-guid")); + var ex = Assert.Throws(() => CoercedValue(SysFields.RowId, "not-a-guid")); Assert.Contains(SysFields.RowId, ex.Message, StringComparison.Ordinal); Assert.DoesNotContain("not-a-guid", ex.Message, StringComparison.Ordinal); @@ -133,7 +134,7 @@ public void Coerce_InvalidValue_Throws() [DisplayName("Text a database might once have converted is refused: non-ISO dates, grouped or trailing-sign numbers, 1 for a boolean, an empty number")] public void Coerce_LenientForms_AreRefused(string field, string text) { - Assert.Throws(() => CoercedValue(field, text)); + Assert.Throws(() => CoercedValue(field, text)); } [Fact] From ef752165a0ee24e48fc96ff3d0397c2567783c5a Mon Sep 17 00:00:00 2001 From: jeff377 Date: Thu, 12 Nov 2026 21:33:17 +0800 Subject: [PATCH 6/6] Describe where InvalidRequestValueException reaches a caller, and skip duplicate test registrations --- docs/en/api/jsonrpc-frontend-integration.md | 2 +- docs/en/changelogs/1.13.0.md | 3 ++- docs/zh-TW/api/jsonrpc-frontend-integration.md | 4 ++-- docs/zh-TW/changelogs/1.13.0.md | 6 +++--- src/Polhem.Api.Core/JsonRpc/JsonRpcErrorCode.cs | 4 +++- src/Polhem.Core/Exceptions/InvalidRequestValueException.cs | 6 ++++-- .../Polhem.Api.Client.UnitTests/ErrorContractDriftTests.cs | 6 +++--- .../SharedDatabaseState.Registration.cs | 7 ++++--- 8 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/en/api/jsonrpc-frontend-integration.md b/docs/en/api/jsonrpc-frontend-integration.md index 762158f4..c2fc6e25 100644 --- a/docs/en/api/jsonrpc-frontend-integration.md +++ b/docs/en/api/jsonrpc-frontend-integration.md @@ -290,7 +290,7 @@ larger than it accepts. Up to 1.1.0 a response also echoed the method name in a | `-32700` | `ParseError` | Malformed JSON in the request body | Fix client serialization | | `-32600` | `InvalidRequest` | Not a valid JSON-RPC request (no `method`, wrong `jsonrpc`), a rejected API key, or a malformed `Authorization` header | Inspect headers and body | | `-32601` | `MethodNotFound` | The method name is not `progId.action`, or names no action the business object exposes; the message is fixed and does not repeat the name | Check method name / casing | -| `-32602` | `InvalidParams` | The parameters carry no value to bind (no `params`, no `value`, or a `null` one); a `Plain` body could not be read into the type the method takes; or, while the host requires the wire frame, a signed-in session called a [replay-protected](api-method-reference.md#replay-protection) method with a `Plain` or `Encoded` payload; the message of these is always "Invalid params.". A filter value that cannot be read as the type of its field answers this code too, with a message naming the field. Other arguments that are readable but invalid answer `-32099` | Fix the `params` shape, or call that method `Encrypted` | +| `-32602` | `InvalidParams` | The parameters carry no value to bind (no `params`, no `value`, or a `null` one); a `Plain` body could not be read into the type the method takes; or, while the host requires the wire frame, a signed-in session called a [replay-protected](api-method-reference.md#replay-protection) method with a `Plain` or `Encoded` payload; the message of these is always "Invalid params.". A filter value that cannot be read as the type of its field answers this code too, with a message naming the field. Other arguments that are readable but invalid answer `-32099` | Fix the `params` shape or the named filter value, or call that method `Encrypted` | | `-32603` | `InternalError` | Unhandled server-side exception (`-32000` up to 1.1.0). An `Encrypted` payload that fails authentication lands here too, so a client whose payload version does not match the server's sees it on every `Encrypted` call | Not user-facing. The message is "Internal server error" unless the server runs in debug mode. When every `Encrypted` call fails this way and other formats work, check that the client and the server are on matching versions | | `-32001` | `Unauthorized` | The method needs a signed-in caller and the access token is missing, unknown or expired | Sign in again | | `-32002` | `CompanyNotEntered` | Method needs company context | Call `System.EnterCompany` first | diff --git a/docs/en/changelogs/1.13.0.md b/docs/en/changelogs/1.13.0.md index ed55e270..8dec0d6c 100644 --- a/docs/en/changelogs/1.13.0.md +++ b/docs/en/changelogs/1.13.0.md @@ -75,7 +75,8 @@ query is built: a `DateTime` field reads a UTC instant (text without an offset i reads the date written, and `Guid`, numeric and `Boolean` fields read their invariant text; dates are read in their ISO 8601 forms only. `String`, `Text` and `Time` fields and the `LIKE` operators keep the string. A string that is not valid for its field's type is refused before the query reaches the database with `-32602 InvalidParams` and a -message naming the field (an in-process caller catches `InvalidRequestValueException`, an `ArgumentException`), +message naming the field (code that calls the business object or the repository directly catches +`InvalidRequestValueException`, an `ArgumentException`), including text a database used to convert on its own, such as `""` for a number or `1` for a boolean. The conversion applies to a string value on any codec; values of any other type are unchanged. A relation field diff --git a/docs/zh-TW/api/jsonrpc-frontend-integration.md b/docs/zh-TW/api/jsonrpc-frontend-integration.md index f615bf09..d359b499 100644 --- a/docs/zh-TW/api/jsonrpc-frontend-integration.md +++ b/docs/zh-TW/api/jsonrpc-frontend-integration.md @@ -1,4 +1,4 @@ - + # JSON-RPC 前端整合指引 [English](../../en/api/jsonrpc-frontend-integration.md) · [← 文件索引](../README.md) @@ -262,7 +262,7 @@ XML 相同。定義在每條路徑上都以 XML 傳輸,因為它們的巢狀 | `-32700` | `ParseError` | request body 不是合法 JSON | 修 client 序列化 | | `-32600` | `InvalidRequest` | 不是有效的 JSON-RPC 請求(缺 `method`、`jsonrpc` 不對)、金鑰被拒,或 `Authorization` header 格式錯誤 | 檢查 headers 與 body | | `-32601` | `MethodNotFound` | 方法名稱不是 `progId.action` 格式,或沒有對應業務物件公開的 action;訊息固定,不會回帶名稱 | 檢查方法名稱 / 大小寫 | -| `-32602` | `InvalidParams` | 參數沒有可繫結的值(沒有 `params`、沒有 `value`,或 `value` 為 `null`);`Plain` 本文無法讀成方法所接受的型別;或 host 要求 wire frame 時,已登入 session 以 `Plain` 或 `Encoded` payload 呼叫[有重放防護](api-method-reference.md#重放防護)的方法;這幾種的訊息固定為「Invalid params.」。過濾值無法讀成其欄位的型別時也回這個代碼,訊息會指出欄位。其他讀得進來但內容不合法的參數回 `-32099` | 修正 `params` 的結構,或改以 `Encrypted` 呼叫該方法 | +| `-32602` | `InvalidParams` | 參數沒有可繫結的值(沒有 `params`、沒有 `value`,或 `value` 為 `null`);`Plain` 本文無法讀成方法所接受的型別;或 host 要求 wire frame 時,已登入 session 以 `Plain` 或 `Encoded` payload 呼叫[有重放防護](api-method-reference.md#重放防護)的方法;這幾種的訊息固定為「Invalid params.」。過濾值無法讀成其欄位的型別時也回這個代碼,訊息會指出欄位。其他讀得進來但內容不合法的參數回 `-32099` | 修正 `params` 的結構或訊息指出的過濾值,或改以 `Encrypted` 呼叫該方法 | | `-32603` | `InternalError` | 未處理的 server 端例外(1.1.0 以前為 `-32000`)。驗證失敗的 `Encrypted` payload 也會落在這裡,所以 payload 版本與伺服端不相符的用戶端,每個 `Encrypted` 呼叫都會收到它 | 訊息不適合對使用者顯示。除非伺服端在 debug 模式,訊息一律為「Internal server error」。若所有 `Encrypted` 呼叫都這樣失敗、其他格式正常,請確認用戶端與伺服端的版本相符 | | `-32001` | `Unauthorized` | 方法需要已登入的呼叫者,而 access token 缺漏、未知或已過期 | 重新登入 | | `-32002` | `CompanyNotEntered` | 方法需要公司 context | 先呼叫 `System.EnterCompany` | diff --git a/docs/zh-TW/changelogs/1.13.0.md b/docs/zh-TW/changelogs/1.13.0.md index 0071da48..358b7c40 100644 --- a/docs/zh-TW/changelogs/1.13.0.md +++ b/docs/zh-TW/changelogs/1.13.0.md @@ -1,4 +1,4 @@ - + # Polhem 1.13.0 — 發佈說明 [English](../../en/changelogs/1.13.0.md) @@ -54,8 +54,8 @@ PostgreSQL 拒絕拿它與有型別的欄位比較(`operator does not exist: d 則把加了引號的小數轉成欄位本身的精度,`'10.5'` 對整數精度的欄位會以 11 比較,不報錯就查無資料。表單的清單、筆數與刪除,現在會在組查詢之前,把過濾條件中的字串值轉成所比對欄位的型別: `DateTime` 欄位讀成 UTC 時間點(沒帶時差的文字視為 UTC),`Date` 欄位讀取寫出的日期,`Guid`、數值與 `Boolean` 欄位讀取與文化無關的文字形式;日期只接受 ISO 8601 形式。`String`、`Text` 與 `Time` 欄位以及 `LIKE` 系列運算子 -保留字串。不符合欄位型別的字串,會在查詢送到資料庫之前以 `-32602 InvalidParams` 拒絕,訊息會指出欄位(同一行程內的呼叫端 -捕捉的是 `InvalidRequestValueException`,它是 `ArgumentException`),包括資料庫過去會自行轉換的文字,例如數字欄位的 +保留字串。不符合欄位型別的字串,會在查詢送到資料庫之前以 `-32602 InvalidParams` 拒絕,訊息會指出欄位(直接呼叫 business object 或 +repository 的程式碼捕捉的是 `InvalidRequestValueException`,它是 `ArgumentException`),包括資料庫過去會自行轉換的文字,例如數字欄位的 `""`、布林欄位的 `1`。 這個轉換適用於任何 codec 送來的字串值;其他型別的值不受影響。relation 欄位依其宣告的 `DbType` 轉換。見 diff --git a/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorCode.cs b/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorCode.cs index 34ef5383..60e09217 100644 --- a/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorCode.cs +++ b/src/Polhem.Api.Core/JsonRpc/JsonRpcErrorCode.cs @@ -31,7 +31,9 @@ public enum JsonRpcErrorCode /// /// Invalid method parameters or incorrect format (-32602): a Plain body could not be read - /// into the type the method takes. Sent with a fixed message. + /// into the type the method takes, or a value in it could not be read as the type it stands for. + /// decides per exception type whether the message is fixed or sent + /// as thrown. /// InvalidParams = -32602, diff --git a/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs b/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs index f5e8ed2f..01e2439f 100644 --- a/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs +++ b/src/Polhem.Core/Exceptions/InvalidRequestValueException.cs @@ -6,8 +6,10 @@ namespace Polhem.Core.Exceptions /// /// /// The JSON-RPC transport sends it as InvalidParams (-32602) with its message, so throw it only with - /// text that names what the caller sent: a field name and a type, never a table, a column or a server detail. It - /// derives from , so an in-process caller that catches that type still sees it. + /// text that names what the caller sent, such as a field name the form declares and its type, never a table name or + /// a server detail. It derives from , so code that calls a business object or a + /// repository directly can catch that type. A call through the JSON-RPC client, the in-process one included, + /// receives the error code instead, because the client rebuilds no exception type for it. /// public sealed class InvalidRequestValueException : ArgumentException { diff --git a/tests/Polhem.Api.Client.UnitTests/ErrorContractDriftTests.cs b/tests/Polhem.Api.Client.UnitTests/ErrorContractDriftTests.cs index 90820ef6..1d4e2e31 100644 --- a/tests/Polhem.Api.Client.UnitTests/ErrorContractDriftTests.cs +++ b/tests/Polhem.Api.Client.UnitTests/ErrorContractDriftTests.cs @@ -47,9 +47,9 @@ private static readonly (JsonRpcErrorCode Code, Type ExceptionType)[] s_reconstr /// /// Error codes deliberately not rebuilt: they arise outside the executor (transport or parsing layer), or - /// they report a protocol mistake whose code already says everything and whose message is not meant for - /// users (, ), so the - /// caller always falls into the generic branch. + /// they report a mistake in the request whose code already says what kind it is + /// (, ), so the + /// caller always falls into the generic branch, which keeps the message. /// private static readonly JsonRpcErrorCode[] s_transportOnlyCodes = [ diff --git a/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs b/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs index ca62afd6..ab3c68ed 100644 --- a/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs +++ b/tests/Polhem.Tests.Shared/SharedDatabaseState.Registration.cs @@ -182,13 +182,14 @@ private static void AddDatabaseItem(string id, string categoryId, DatabaseType d // WARNING: the DatabaseSettings instance in the cache is shared by every test running in the process, and // other test classes read it while this runs. It is never changed in place: the entries are added to a copy, // and the copy replaces the cached instance in one step, so a reader sees either the old instance or the - // complete new one. The cache slot is keyed by type name alone, so a DatabaseSettingsCache built over any - // PathOptions replaces the slot every define access reads. + // complete new one. The slot is keyed by the type name and the container's prefix; the fixtures read the + // unprefixed one, which a DatabaseSettingsCache built over any PathOptions replaces. private static void ApplyRegistrations(IDefineAccess access, PathOptions paths) { var current = access.GetDatabaseSettings(); var servers = s_registeredServers.Where(s => !current.Servers!.Contains(s.Id)).ToList(); - var items = s_registeredItems.Where(i => !current.Items!.Contains(i.Id)).ToList(); + var items = s_registeredItems.Where(i => !current.Items!.Contains(i.Id)) + .DistinctBy(i => i.Id, StringComparer.OrdinalIgnoreCase).ToList(); if (servers.Count == 0 && items.Count == 0) return; var copy = current.Clone();