From c693337ebf377f698d019aa843872ed09bb8becd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:06:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8EJSON=20schema=E7=9A=84=E9=85=8D=E7=BD=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了SchemaConfigGenerator源代码生成器 - 支持根据JSON schema文件自动生成配置类型 - 生成强类型的配置表包装类 - 支持嵌套对象和对象数组的类型生成 - 生成配置表的查询和索引功能 - 添加了跨表引用的元数据支持 - 生成运行时注册和访问辅助代码 - 支持默认值、枚举和约束的文档生成 --- .../Config/YamlConfigLoaderTests.cs | 102 +++++++ .../Config/YamlConfigSchemaValidator.cs | 268 +++++++++++++++++- .../SchemaConfigGeneratorSnapshotTests.cs | 2 + .../SchemaConfigGenerator/MonsterConfig.g.txt | 4 +- .../Config/SchemaConfigGenerator.cs | 18 +- 5 files changed, 386 insertions(+), 8 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 059d61aa..488c3427 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -510,6 +510,54 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数值不满足 multipleOf 时会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_MultipleOf() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 12 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "multipleOf": 5 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("hp")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("12")); + Assert.That(exception.Message, Does.Contain("multiple of 5")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -762,6 +810,60 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数组声明 uniqueItems 后,重复元素会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_UniqueItems() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 5 + - 10 + - 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer" + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates[2]")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("5")); + Assert.That(exception.Message, Does.Contain("unique array items")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 381f321c..c1f85a3e 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -7,6 +7,8 @@ namespace GFramework.Game.Config; /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 /// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 +/// 当前共享子集额外支持 multipleOfuniqueItems, +/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -603,6 +605,8 @@ internal static class YamlConfigSchemaValidator schemaNode.ItemNode, references); } + + ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode); } /// @@ -776,6 +780,7 @@ internal static class YamlConfigSchemaValidator TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum"); var exclusiveMaximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum"); + var multipleOf = TryParseMultipleOfConstraint(tableName, schemaPath, propertyPath, element, nodeType); var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); @@ -813,6 +818,7 @@ internal static class YamlConfigSchemaValidator !maximum.HasValue && !exclusiveMinimum.HasValue && !exclusiveMaximum.HasValue && + !multipleOf.HasValue && !minLength.HasValue && !maxLength.HasValue && pattern is null) @@ -825,6 +831,7 @@ internal static class YamlConfigSchemaValidator maximum, exclusiveMinimum, exclusiveMaximum, + multipleOf, minLength, maxLength, pattern, @@ -851,6 +858,7 @@ internal static class YamlConfigSchemaValidator { var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); + var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element); if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) { @@ -862,9 +870,9 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return !minItems.HasValue && !maxItems.HasValue + return !minItems.HasValue && !maxItems.HasValue && !uniqueItems ? null - : new YamlConfigArrayConstraints(minItems, maxItems); + : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems); } /// @@ -917,6 +925,41 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取 multipleOf 约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 步进约束;未声明时返回空。 + private static double? TryParseMultipleOfConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + var multipleOf = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf"); + if (!multipleOf.HasValue) + { + return null; + } + + if (multipleOf.Value <= 0d) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'multipleOf' as a positive finite number.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return multipleOf; + } + /// /// 读取字符串长度约束。 /// @@ -1062,6 +1105,39 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取数组去重约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 是否启用 uniqueItems + private static bool TryParseUniqueItemsConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + if (!element.TryGetProperty("uniqueItems", out var constraintElement)) + { + return false; + } + + if (constraintElement.ValueKind != JsonValueKind.True && + constraintElement.ValueKind != JsonValueKind.False) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'uniqueItems' as a boolean.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintElement.GetBoolean(); + } + /// /// 校验数值上下界组合不会形成空区间。 /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。 @@ -1238,6 +1314,20 @@ internal static class YamlConfigSchemaValidator $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.MultipleOf.HasValue && + !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); + } + return; case YamlConfigSchemaPropertyType.String: @@ -1345,6 +1435,159 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组是否满足去重约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 实际数组节点。 + /// 数组 schema 节点。 + private static void ValidateArrayUniqueItemsConstraint( + string tableName, + string yamlPath, + string displayPath, + YamlSequenceNode sequenceNode, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.ArrayConstraints; + if (constraints is null || + !constraints.UniqueItems || + schemaNode.ItemNode is null) + { + return; + } + + // The canonical item key uses schema-aware normalization so object key order, + // scalar quoting, and numeric formatting do not accidentally bypass uniqueItems. + Dictionary seenItems = new(StringComparer.Ordinal); + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) + { + var itemNode = sequenceNode.Children[itemIndex]; + var comparableValue = BuildComparableNodeValue(itemNode, schemaNode.ItemNode); + if (seenItems.TryGetValue(comparableValue, out var existingIndex)) + { + var itemPath = $"{displayPath}[{itemIndex}]"; + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' requires unique array items, but item '{itemPath}' duplicates '{displayPath}[{existingIndex}]'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: itemPath, + rawValue: DescribeYamlNodeForDiagnostics(itemNode, schemaNode.ItemNode), + detail: "The schema declares uniqueItems = true."); + } + + seenItems.Add(comparableValue, itemIndex); + } + } + + /// + /// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。 + /// 该键仅用于 uniqueItems,因此要忽略对象字段顺序和字符串引号形式。 + /// + /// YAML 节点。 + /// 对应 schema 节点。 + /// 可稳定比较的归一化键。 + private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + switch (schemaNode.NodeType) + { + case YamlConfigSchemaPropertyType.Object: + if (node is not YamlMappingNode mappingNode) + { + throw new InvalidOperationException("Validated object nodes must be YAML mappings."); + } + + var objectEntries = new List>(mappingNode.Children.Count); + foreach (var entry in mappingNode.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + keyNode.Value is null || + schemaNode.Properties is null || + !schemaNode.Properties.TryGetValue(keyNode.Value, out var propertySchema)) + { + throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); + } + + objectEntries.Add( + new KeyValuePair( + keyNode.Value, + BuildComparableNodeValue(entry.Value, propertySchema))); + } + + objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); + return string.Join( + "|", + objectEntries.Select(static entry => + $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); + + case YamlConfigSchemaPropertyType.Array: + if (node is not YamlSequenceNode sequenceNode || + schemaNode.ItemNode is null) + { + throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); + } + + return "[" + + string.Join( + ",", + sequenceNode.Children.Select( + item => BuildComparableNodeValue(item, schemaNode.ItemNode))) + + "]"; + + case YamlConfigSchemaPropertyType.Integer: + case YamlConfigSchemaPropertyType.Number: + case YamlConfigSchemaPropertyType.Boolean: + case YamlConfigSchemaPropertyType.String: + if (node is not YamlScalarNode scalarNode || + scalarNode.Value is null) + { + throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); + } + + var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); + return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; + + default: + throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'."); + } + } + + /// + /// 为唯一性诊断提取一个可读的节点摘要。 + /// + /// YAML 节点。 + /// 对应 schema 节点。 + /// 诊断摘要。 + private static string DescribeYamlNodeForDiagnostics(YamlNode node, YamlConfigSchemaNode schemaNode) + { + return schemaNode.NodeType switch + { + YamlConfigSchemaPropertyType.Object => "{...}", + YamlConfigSchemaPropertyType.Array => "[...]", + _ when node is YamlScalarNode scalarNode => scalarNode.Value ?? string.Empty, + _ => node.GetType().Name + }; + } + + /// + /// 判断数值是否满足 multipleOf。 + /// 双精度浮点比较会保留一个与商值量级相关的微小容差, + /// 以避免运行时与 JS 工具侧在 0.1 / 0.01 这类十进制步进上出现伪失败。 + /// + /// 当前值。 + /// 步进约束。 + /// 是否满足整倍数关系。 + private static bool IsMultipleOf(double value, double divisor) + { + var quotient = value / divisor; + var nearestInteger = Math.Round(quotient); + var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient)); + return Math.Abs(quotient - nearestInteger) <= tolerance; + } + /// /// 解析跨表引用目标表名称。 /// @@ -1758,7 +2001,7 @@ internal sealed class YamlConfigSchemaNode } /// -/// 表示一个标量节点上声明的数值范围或字符串长度约束。 +/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。 /// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 /// internal sealed class YamlConfigScalarConstraints @@ -1770,6 +2013,7 @@ internal sealed class YamlConfigScalarConstraints /// 最大值约束。 /// 开区间最小值约束。 /// 开区间最大值约束。 + /// 数值步进约束。 /// 最小长度约束。 /// 最大长度约束。 /// 正则模式约束。 @@ -1779,6 +2023,7 @@ internal sealed class YamlConfigScalarConstraints double? maximum, double? exclusiveMinimum, double? exclusiveMaximum, + double? multipleOf, int? minLength, int? maxLength, string? pattern, @@ -1788,6 +2033,7 @@ internal sealed class YamlConfigScalarConstraints Maximum = maximum; ExclusiveMinimum = exclusiveMinimum; ExclusiveMaximum = exclusiveMaximum; + MultipleOf = multipleOf; MinLength = minLength; MaxLength = maxLength; Pattern = pattern; @@ -1814,6 +2060,11 @@ internal sealed class YamlConfigScalarConstraints /// public double? ExclusiveMaximum { get; } + /// + /// 获取数值步进约束。 + /// + public double? MultipleOf { get; } + /// /// 获取最小长度约束。 /// @@ -1836,7 +2087,7 @@ internal sealed class YamlConfigScalarConstraints } /// -/// 表示一个数组节点上声明的元素数量约束。 +/// 表示一个数组节点上声明的元素数量或去重约束。 /// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 /// internal sealed class YamlConfigArrayConstraints @@ -1846,10 +2097,12 @@ internal sealed class YamlConfigArrayConstraints /// /// 最小元素数量约束。 /// 最大元素数量约束。 - public YamlConfigArrayConstraints(int? minItems, int? maxItems) + /// 是否要求数组元素唯一。 + public YamlConfigArrayConstraints(int? minItems, int? maxItems, bool uniqueItems) { MinItems = minItems; MaxItems = maxItems; + UniqueItems = uniqueItems; } /// @@ -1861,6 +2114,11 @@ internal sealed class YamlConfigArrayConstraints /// 获取最大元素数量约束。 /// public int? MaxItems { get; } + + /// + /// 获取是否要求数组元素唯一。 + /// + public bool UniqueItems { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 290985ee..dc2a36b5 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -92,6 +92,7 @@ public class SchemaConfigGeneratorSnapshotTests "maximum": 999, "exclusiveMinimum": 0, "exclusiveMaximum": 1000, + "multipleOf": 5, "default": 10 }, "dropItems": { @@ -99,6 +100,7 @@ public class SchemaConfigGeneratorSnapshotTests "type": "array", "minItems": 1, "maxItems": 3, + "uniqueItems": true, "items": { "type": "string", "minLength": 3, diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 2306fa00..d5b5f909 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -34,7 +34,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'hp'. - /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000. + /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000, multipleOf = 5. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -45,7 +45,7 @@ public sealed partial class MonsterConfig /// /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. - /// Constraints: minItems = 1, maxItems = 3. + /// Constraints: minItems = 1, maxItems = 3, uniqueItems = true. /// References config table: 'item'. /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index ca249109..5890cc1f 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -6,6 +6,8 @@ namespace GFramework.SourceGenerators.Config; /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。 +/// 当前共享子集也会把 multipleOfuniqueItems 写入生成代码文档, +/// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator @@ -2430,7 +2432,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -2463,6 +2465,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "multipleOf", out var multipleOf) && + multipleOf > 0d) + { + parts.Add($"multipleOf = {multipleOf.ToString(CultureInfo.InvariantCulture)}"); + } + if (schemaType == "string" && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { @@ -2494,6 +2503,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}"); } + if (schemaType == "array" && + element.TryGetProperty("uniqueItems", out var uniqueItemsElement) && + uniqueItemsElement.ValueKind == JsonValueKind.True) + { + parts.Add("uniqueItems = true"); + } + return parts.Count > 0 ? string.Join(", ", parts) : null; }