diff --git a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md index 4d7f7481..e6d7b883 100644 --- a/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/GFramework.Game.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -16,3 +16,4 @@ GF_ConfigSchema_009 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_010 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics GF_ConfigSchema_011 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics + GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 45180c9c..d4103b9c 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -9,8 +9,8 @@ namespace GFramework.Game.SourceGenerators.Config; /// 当前共享子集也会把 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemas 与稳定字符串 format 子集写入生成代码文档, -/// 让消费者能直接在强类型 API 上看到运行时生效的约束。 +/// dependentSchemasallOf 与稳定字符串 format 子集写入生成代码文档, +/// 让消费者能直接在强类型 API 上看到运行时生效且不改变生成类型形状的约束。 /// [Generator] public sealed class SchemaConfigGenerator : IIncrementalGenerator @@ -160,6 +160,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return SchemaParseResult.FromDiagnostic(dependentSchemasDiagnostic!); } + if (!TryValidateAllOfMetadataRecursively( + file.Path, + "", + root, + out var allOfDiagnostic)) + { + return SchemaParseResult.FromDiagnostic(allOfDiagnostic!); + } + var entityName = ToPascalCase(GetSchemaBaseName(file.Path)); var rootObject = ParseObjectSpec( file.Path, @@ -681,7 +690,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// /// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。 - /// 该遍历覆盖对象属性、dependentSchemas / not 子 schema、 + /// 该遍历覆盖对象属性、dependentSchemas / allOf / not 子 schema、 /// 数组 itemscontains, /// 避免不同关键字验证器在同一棵 schema 树上各自维护一份容易漂移的递归流程。 /// @@ -759,6 +768,33 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (string.Equals(schemaType, "object", StringComparison.Ordinal) && + element.TryGetProperty("allOf", out var allOfElement) && + allOfElement.ValueKind == JsonValueKind.Array) + { + var allOfIndex = 0; + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + allOfIndex++; + continue; + } + + if (!TryTraverseSchemaRecursively( + filePath, + BuildAllOfEntryPath(displayPath, allOfIndex), + allOfSchema, + nodeValidator, + out diagnostic)) + { + return false; + } + + allOfIndex++; + } + } + if (element.TryGetProperty("not", out var notElement) && notElement.ValueKind == JsonValueKind.Object && !TryTraverseSchemaRecursively( @@ -803,6 +839,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 为对象级 allOf 条目生成与运行时一致的逻辑路径。 + /// + /// 父对象路径。 + /// 从 0 开始的条目索引。 + /// 格式化后的 allOf 条目路径。 + private static string BuildAllOfEntryPath(string displayPath, int allOfIndex) + { + return $"{displayPath}[allOf[{allOfIndex}]]"; + } + /// /// 递归验证 schema 树中的对象级 dependentRequired 元数据。 /// 该遍历会覆盖根节点、dependentSchemas / not 子 schema、 @@ -1122,6 +1169,283 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return true; } + /// + /// 验证当前 schema 节点是否以运行时支持的方式声明了 allOf。 + /// 当前共享子集只接受 object 节点上的 object-typed inline schema 数组, + /// 以便把它们解释成 focused constraint block,而不引入额外的类型合并语义。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 当前节点声明的 schema 类型。 + /// 失败时返回的诊断。 + /// 当前节点上的 allOf 声明是否有效。 + private static bool TryValidateAllOfDeclaration( + string filePath, + string displayPath, + JsonElement element, + string? schemaType, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("allOf", out _)) + { + return true; + } + + if (!string.Equals(schemaType, "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Only object schemas can declare 'allOf'."); + return false; + } + + return TryValidateAllOfMetadata(filePath, displayPath, element, out diagnostic); + } + + /// + /// 递归验证 schema 树中的对象级 allOf 元数据。 + /// 该遍历会覆盖根节点、not、数组元素、containsdependentSchemas + /// 与嵌套 allOf,确保生成器对组合约束的接受范围与运行时保持一致。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前 schema 节点。 + /// 失败时返回的诊断。 + /// 当前节点树的 allOf 元数据是否有效。 + private static bool TryValidateAllOfMetadataRecursively( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + return TryTraverseSchemaRecursively( + filePath, + displayPath, + element, + static (currentFilePath, currentDisplayPath, currentElement, schemaType) => + { + return TryValidateAllOfDeclaration( + currentFilePath, + currentDisplayPath, + currentElement, + schemaType, + out var currentDiagnostic) + ? (true, (Diagnostic?)null) + : (false, currentDiagnostic); + }, + out diagnostic); + } + + /// + /// 验证单个对象 schema 节点上的 allOf 元数据。 + /// 生成器当前只接受 object-typed inline schema 数组, + /// 避免 XML 文档描述出运行时不会按 focused constraint block 解释的组合形状。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 当前对象 schema 节点。 + /// 失败时返回的诊断。 + /// 当前对象上的 allOf 元数据是否有效。 + private static bool TryValidateAllOfMetadata( + string filePath, + string displayPath, + JsonElement element, + out Diagnostic? diagnostic) + { + diagnostic = null; + if (!element.TryGetProperty("allOf", out var allOfElement)) + { + return true; + } + + if (allOfElement.ValueKind != JsonValueKind.Array) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "The 'allOf' value must be an array."); + return false; + } + + if (!element.TryGetProperty("properties", out var propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + "Object schemas using 'allOf' must also declare an object-valued 'properties' map."); + return false; + } + + var declaredProperties = new HashSet( + propertiesElement + .EnumerateObject() + .Select(static property => property.Name), + StringComparer.Ordinal); + + var allOfIndex = 0; + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must be an object-valued schema."); + return false; + } + + if (!allOfSchema.TryGetProperty("type", out var allOfTypeElement) || + allOfTypeElement.ValueKind != JsonValueKind.String || + !string.Equals(allOfTypeElement.GetString(), "object", StringComparison.Ordinal)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare an object-typed schema."); + return false; + } + + if (!TryValidateAllOfEntryTargets( + filePath, + displayPath, + allOfSchema, + allOfIndex, + declaredProperties, + out diagnostic)) + { + return false; + } + + allOfIndex++; + } + + return true; + } + + /// + /// 验证单个 allOf 条目只约束父对象已声明的同级字段。 + /// + /// Schema 文件路径。 + /// 父对象逻辑路径。 + /// 当前 allOf 条目。 + /// 从 0 开始的条目索引。 + /// 父对象已声明属性集合。 + /// 失败时返回的诊断。 + /// 当前 allOf 条目是否只引用父对象已声明字段。 + private static bool TryValidateAllOfEntryTargets( + string filePath, + string displayPath, + JsonElement allOfSchema, + int allOfIndex, + ISet declaredProperties, + out Diagnostic? diagnostic) + { + diagnostic = null; + var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex); + + if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement)) + { + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'properties' as an object-valued map."); + return false; + } + + foreach (var property in allOfPropertiesElement.EnumerateObject()) + { + if (declaredProperties.Contains(property.Name)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' declares property '{property.Name}', but that property is not declared in the parent object schema."); + return false; + } + } + + if (!allOfSchema.TryGetProperty("required", out var requiredElement)) + { + return true; + } + + if (requiredElement.ValueKind != JsonValueKind.Array) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'required' as an array of parent property names."); + return false; + } + + foreach (var requiredProperty in requiredElement.EnumerateArray()) + { + if (requiredProperty.ValueKind != JsonValueKind.String) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'required' entries as parent property-name strings."); + return false; + } + + var requiredPropertyName = requiredProperty.GetString(); + if (string.IsNullOrWhiteSpace(requiredPropertyName)) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' cannot declare blank property names in 'required'."); + return false; + } + + var normalizedRequiredPropertyName = requiredPropertyName!; + if (declaredProperties.Contains(normalizedRequiredPropertyName)) + { + continue; + } + + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' requires property '{normalizedRequiredPropertyName}', but that property is not declared in the parent object schema."); + return false; + } + + return true; + } + /// /// 判断给定 format 名称是否属于当前共享支持子集。 /// @@ -3215,7 +3539,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains 与对象属性数量约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重 / contains、 + /// 对象属性数量 / dependent* / allOf 约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -3362,6 +3687,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { parts.Add($"dependentSchemas = {dependentSchemasDocumentation}"); } + + var allOfDocumentation = TryBuildAllOfDocumentation(element); + if (allOfDocumentation is not null) + { + parts.Add($"allOf = {allOfDocumentation}"); + } } return parts.Count > 0 ? string.Join(", ", parts) : null; @@ -3443,6 +3774,39 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator : null; } + /// + /// 将对象 allOf 组合约束整理成 XML 文档可读字符串。 + /// + /// 对象 schema 节点。 + /// 格式化后的 allOf 说明。 + private static string? TryBuildAllOfDocumentation(JsonElement element) + { + if (!element.TryGetProperty("allOf", out var allOfElement) || + allOfElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var parts = new List(); + foreach (var allOfSchema in allOfElement.EnumerateArray()) + { + if (allOfSchema.ValueKind != JsonValueKind.Object) + { + continue; + } + + var summary = TryBuildInlineSchemaSummary(allOfSchema, includeRequiredProperties: true); + if (summary is not null) + { + parts.Add(summary); + } + } + + return parts.Count > 0 + ? $"[ {string.Join("; ", parts)} ]" + : null; + } + /// /// 将数组 contains 子 schema 整理成 XML 文档可读字符串。 /// 输出优先保持紧凑,只展示消费者在强类型 API 上最需要看到的匹配摘要。 diff --git a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs index da58e3d9..c39d275f 100644 --- a/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs +++ b/GFramework.Game.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs @@ -129,4 +129,15 @@ public static class ConfigSchemaDiagnostics SourceGeneratorsConfigCategory, DiagnosticSeverity.Error, true); + + /// + /// schema 对象节点的 allOf 元数据无效。 + /// + public static readonly DiagnosticDescriptor InvalidAllOfMetadata = new( + "GF_ConfigSchema_012", + "Config schema uses invalid allOf metadata", + "Property '{1}' in schema file '{0}' uses invalid 'allOf' metadata: {2}", + SourceGeneratorsConfigCategory, + DiagnosticSeverity.Error, + true); } diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs new file mode 100644 index 00000000..09f0eebb --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -0,0 +1,680 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对对象级 allOf 组合约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderAllOfTests +{ + private const string DefaultRewardPropertiesJson = """ + { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + } + """; + + private const string DefaultAllOfJson = """ + [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + } + ] + """; + + private string? _rootPath; + + /// + /// 为每个用例创建隔离的临时目录,避免不同 allOf 场景互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理当前测试创建的目录,避免本地临时文件堆积。 + /// + [TearDown] + public void TearDown() + { + if (!string.IsNullOrEmpty(_rootPath) && + Directory.Exists(_rootPath)) + { + try + { + Directory.Delete(_rootPath, true); + } + catch (Exception) + { + // Ignore cleanup failures in test teardown + } + } + } + + /// + /// 验证当前对象未满足任一 allOf 条目时,运行时会拒绝加载。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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("reward")); + Assert.That(exception.Message, Does.Contain("allOf")); + Assert.That(exception.Message, Does.Contain("entry #1")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象满足全部 allOf 条目时,可以保留未在 focused block 中重复声明的同级字段。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_All_AllOf_Entries_Are_Satisfied() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemId: potion + itemCount: 3 + bonus: 1 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema(DefaultRewardPropertiesJson, DefaultAllOfJson)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + var reward = table.Get(1).Reward; + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(reward.ItemId, Is.EqualTo("potion")); + Assert.That(reward.ItemCount, Is.EqualTo(3)); + Assert.That(reward.Bonus, Is.EqualTo(1)); + }); + } + + /// + /// 验证非数组 allOf 声明会在 schema 解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Is_Not_An_Array() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("must declare 'allOf' as an array")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证只有对象字段允许声明 allOf。 + /// + [Test] + public void LoadAsync_Should_Throw_When_NonObject_Schema_Declares_AllOf() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + tag: elite + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + """); + + ArgumentNullException.ThrowIfNull(_rootPath); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("tag")); + Assert.That(exception.Message, Does.Contain("can only declare 'allOf' on object schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目必须是对象值 schema。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Valued() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [123] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward")); + Assert.That(exception.Message, Does.Contain("allOf' entries as object-valued schemas")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目只接受 object-typed schema。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Is_Not_Object_Typed() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "string", + "const": "potion" + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("object-typed schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 properties 必须声明为对象映射。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Properties_Is_Not_Object_Valued() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "properties": 1 + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'properties' as an object-valued map")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 required 必须声明为字段名数组。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Is_Not_An_Array() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": {} + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'required' as an array of property names")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 required 项必须是字符串字段名。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Item_Is_Not_A_String() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": [1] + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'required' entries as property-name strings")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 required 不允许空白字段名。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Item_Is_Blank() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": [""] + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("cannot declare blank property names in 'required'")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目不能要求父对象未声明的字段。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Requires_Undeclared_Parent_Property() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + """ + { + "itemCount": { "type": "integer" } + } + """, + """ + [ + { + "type": "object", + "required": ["bonus"] + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("requires property 'bonus'")); + Assert.That(exception.Message, Does.Contain("parent object schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 在测试目录下写入配置文件,并自动创建缺失目录。 + /// + /// 相对根目录的配置文件路径。 + /// 要写入的 YAML 或 schema 内容。 + private void CreateConfigFile(string relativePath, string content) + { + ArgumentNullException.ThrowIfNull(_rootPath); + + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 写入测试 schema 文件,复用统一的测试文件创建逻辑。 + /// + /// schema 相对路径。 + /// schema JSON 内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + + /// + /// 构建带有指定奖励内容的怪物配置 YAML 文本。 + /// + /// 奖励对象的 YAML 片段。 + /// 完整的怪物配置 YAML 文本。 + private static string BuildMonsterConfigYaml(string rewardYaml) + { + return $$""" + id: 1 + reward: + {{IndentLines(rewardYaml, 2)}} + """; + } + + /// + /// 构建带有指定奖励属性和 allOf 约束的怪物 schema JSON。 + /// + /// 奖励对象的 properties JSON 片段。 + /// allOf 约束的 JSON 数组片段。 + /// 完整的 schema JSON 文本。 + private static string BuildMonsterSchema( + string rewardPropertiesJson, + string allOfJson) + { + return $$""" + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": {{rewardPropertiesJson}}, + "allOf": {{allOfJson}} + } + } + } + """; + } + + /// + /// 为多行文本的每一行添加指定数量的空格缩进。 + /// + /// 原始文本。 + /// 缩进空格数。 + /// 添加缩进后的文本。 + private static string IndentLines(string text, int indentLevel) + { + var indentation = new string(' ', indentLevel); + var lines = text + .Trim() + .Split('\n', StringSplitOptions.None) + .Select(static line => line.TrimEnd('\r')); + + return string.Join( + Environment.NewLine, + lines.Select(line => $"{indentation}{line}")); + } + + /// + /// 创建用于对象 allOf 场景的加载器。 + /// + /// 已注册测试表与 schema 路径的加载器。 + private YamlConfigLoader CreateMonsterRewardLoader() + { + ArgumentNullException.ThrowIfNull(_rootPath); + + return new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + } + + /// + /// 创建新的配置注册表,确保每个用例从干净状态开始。 + /// + /// 空的配置注册表。 + private static ConfigRegistry CreateRegistry() + { + return new ConfigRegistry(); + } + + /// + /// 用于对象 allOf 回归测试的最小配置类型。 + /// + private sealed class MonsterAllOfConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public AllOfRewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示对象 allOf 回归测试中的奖励节点。 + /// + private sealed class AllOfRewardConfigStub + { + /// + /// 获取或设置掉落物 ID。 + /// + public string ItemId { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物数量。 + /// + public int ItemCount { get; set; } + + /// + /// 获取或设置额外奖励值。 + /// + public int Bonus { get; set; } + } + + /// + /// 用于非对象 allOf 场景回归测试的最小配置类型。 + /// + private sealed class MonsterTagConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置标签。 + /// + public string Tag { get; set; } = string.Empty; + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs index 26acb434..7a61a7e9 100644 --- a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs @@ -126,6 +126,61 @@ public sealed class YamlConfigSchemaValidatorTests }); } + /// + /// 验证 allOf focused block 复用同一条 ref-table 字段时,不会把同一引用重复写入结果。 + /// + [Test] + public void ValidateAndCollectReferences_Should_Not_Duplicate_Reference_Usages_From_AllOf() + { + var schemaPath = CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + }, + "allOf": [ + { + "type": "object", + "properties": { + "itemId": { + "type": "string", + "x-gframework-ref-table": "item" + } + } + } + ] + } + } + } + """); + var schema = YamlConfigSchemaValidator.Load("monster", schemaPath); + + var references = YamlConfigSchemaValidator.ValidateAndCollectReferences( + "monster", + schema, + "monster/slime.yaml", + """ + reward: + itemId: potion + """); + + Assert.That(references, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(references[0].DisplayPath, Is.EqualTo("reward.itemId")); + Assert.That(references[0].ReferencedTableName, Is.EqualTo("item")); + Assert.That(references[0].RawValue, Is.EqualTo("potion")); + }); + } + /// /// 在临时目录中创建 schema 文件。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs new file mode 100644 index 00000000..145ab2f9 --- /dev/null +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.ObjectKeywords.cs @@ -0,0 +1,494 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 承载对象级 schema 关键字的解析与元数据校验逻辑。 +/// 该 partial 将 minPropertiesmaxProperties、 +/// dependentRequireddependentSchemasallOf +/// 从主校验文件中拆出,降低超大文件继续堆叠对象关键字时的维护成本。 +/// +internal static partial class YamlConfigSchemaValidator +{ + /// + /// 解析对象节点支持的属性数量约束与对象关键字约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 对象约束模型;未声明时返回空。 + private static YamlConfigObjectConstraints? ParseObjectConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + var minProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "minProperties"); + var maxProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "maxProperties"); + var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); + var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); + var allOfSchemas = ParseAllOfConstraints(tableName, schemaPath, propertyPath, element, properties); + + if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) + { + var targetDescription = DescribeObjectSchemaTarget(propertyPath); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null && + allOfSchemas is null + ? null + : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas, allOfSchemas); + } + + /// + /// 解析对象节点声明的 dependentRequired 依赖关系。 + /// 该关键字只表达“当触发字段出现时,还必须同时声明哪些同级字段”, + /// 因此这里会把触发字段与依赖字段都限制在当前对象已声明的属性集合内, + /// 避免运行时与工具链对无效键名各自做隐式容错。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 归一化后的依赖关系表;未声明或只有空依赖时返回空。 + private static IReadOnlyDictionary>? ParseDependentRequiredConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement)) + { + return null; + } + + if (dependentRequiredElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentRequired' as an object.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependentRequired = new Dictionary>(StringComparer.Ordinal); + foreach (var dependency in dependentRequiredElement.EnumerateObject()) + { + if (!properties.ContainsKey(dependency.Name)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' for undeclared property '{dependency.Name}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (dependency.Value.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencyTargets = new List(); + var seenDependencyTargets = new HashSet(StringComparer.Ordinal); + foreach (var dependencyTarget in dependency.Value.EnumerateArray()) + { + if (dependencyTarget.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencyTargetName = dependencyTarget.GetString(); + if (string.IsNullOrWhiteSpace(dependencyTargetName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (!properties.ContainsKey(dependencyTargetName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' target '{dependencyTargetName}' that is not declared in the same object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (seenDependencyTargets.Add(dependencyTargetName)) + { + dependencyTargets.Add(dependencyTargetName); + } + } + + if (dependencyTargets.Count > 0) + { + dependentRequired[dependency.Name] = dependencyTargets; + } + } + + return dependentRequired.Count == 0 + ? null + : dependentRequired; + } + + /// + /// 解析对象节点声明的 dependentSchemas 条件 schema。 + /// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释, + /// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 当前对象已声明的属性集合。 + /// 归一化后的触发字段到条件 schema 的映射;未声明时返回空。 + private static IReadOnlyDictionary? ParseDependentSchemasConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) + { + return null; + } + + if (dependentSchemasElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependentSchemas = new Dictionary(StringComparer.Ordinal); + foreach (var dependency in dependentSchemasElement.EnumerateObject()) + { + if (!properties.ContainsKey(dependency.Name)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (dependency.Value.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}"); + var dependencySchemaNode = ParseNode( + tableName, + schemaPath, + dependencySchemaPath, + dependency.Value); + if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(dependencySchemaPath)); + } + + dependentSchemas[dependency.Name] = dependencySchemaNode; + } + + return dependentSchemas.Count == 0 + ? null + : dependentSchemas; + } + + /// + /// 解析对象节点声明的 allOf 组合约束。 + /// 当前实现仅接受 object-typed 内联 schema,并把每个条目当成 focused constraint block + /// 叠加到当前对象上,而不是参与属性合并或改变生成类型形状。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 父对象已声明的属性集合。 + /// 归一化后的 allOf schema 列表;未声明或为空时返回空。 + private static IReadOnlyList? ParseAllOfConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + IReadOnlyDictionary properties) + { + if (!element.TryGetProperty("allOf", out var allOfElement)) + { + return null; + } + + if (allOfElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' as an array of object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemas = new List(); + var allOfIndex = 0; + foreach (var allOfSchemaElement in allOfElement.EnumerateArray()) + { + if (allOfSchemaElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'allOf' entries as object-valued schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var allOfSchemaPath = BuildNestedSchemaPath(propertyPath, $"allOf[{allOfIndex.ToString(CultureInfo.InvariantCulture)}]"); + ValidateAllOfSchemaTargetsAgainstParentObject( + tableName, + schemaPath, + propertyPath, + allOfSchemaPath, + allOfIndex + 1, + allOfSchemaElement, + properties); + var allOfSchemaNode = ParseNode( + tableName, + schemaPath, + allOfSchemaPath, + allOfSchemaElement); + if (allOfSchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{(allOfIndex + 1).ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + allOfSchemas.Add(allOfSchemaNode); + allOfIndex++; + } + + return allOfSchemas.Count == 0 + ? null + : allOfSchemas; + } + + /// + /// 验证 allOf 条目只约束父对象已经声明过的同级字段。 + /// 当前 object-focused 语义不会把条目里的属性并回父对象形状,因此这里要提前拒绝 + /// “在 focused block 里引入父对象未声明字段”的不可满足 schema。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 父对象路径。 + /// 当前 allOf 条目路径。 + /// 从 1 开始的 allOf 条目编号。 + /// 当前 allOf 条目。 + /// 父对象已声明的属性集合。 + private static void ValidateAllOfSchemaTargetsAgainstParentObject( + string tableName, + string schemaPath, + string propertyPath, + string allOfSchemaPath, + int allOfEntryNumber, + JsonElement allOfSchemaElement, + IReadOnlyDictionary properties) + { + if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement)) + { + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + foreach (var property in allOfPropertiesElement.EnumerateObject()) + { + if (properties.ContainsKey(property.Name)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' declares property '{property.Name}', but that property is not declared in the parent object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + } + + if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement)) + { + return; + } + + if (allOfRequiredElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + foreach (var requiredProperty in allOfRequiredElement.EnumerateArray()) + { + if (requiredProperty.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' entries as property-name strings.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + var requiredPropertyName = requiredProperty.GetString(); + if (string.IsNullOrWhiteSpace(requiredPropertyName)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank property names in 'required'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + + if (properties.ContainsKey(requiredPropertyName)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' requires property '{requiredPropertyName}', but that property is not declared in the parent object schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + } + + /// + /// 读取对象属性数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 关键字名称。 + /// 属性数量约束;未声明时返回空。 + private static int? TryParseObjectPropertyCountConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + if (constraintElement.ValueKind != JsonValueKind.Number || + !constraintElement.TryGetInt32(out var constraintValue) || + constraintValue < 0) + { + var targetDescription = DescribeObjectSchemaTarget(propertyPath); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintValue; + } + + /// + /// 为对象级 schema 关键字构造稳定的诊断主体。 + /// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 Property '' 之类的文本。 + /// + /// 对象字段路径。 + /// 用于错误消息的对象主体描述。 + private static string DescribeObjectSchemaTarget(string propertyPath) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? "Root object" + : $"Property '{propertyPath}'"; + } + + /// + /// 为插入句中位置的对象级 schema 关键字构造稳定描述。 + /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。 + /// + /// 对象字段路径。 + /// 可直接拼接到句中介词后的对象主体描述。 + private static string DescribeObjectSchemaTargetInClause(string propertyPath) + { + return string.IsNullOrWhiteSpace(propertyPath) + ? "root object" + : $"property '{propertyPath}'"; + } +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index cb527344..dabff389 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -11,11 +11,11 @@ namespace GFramework.Game.Config; /// 当前共享子集额外支持 multipleOfuniqueItems、 /// contains / minContains / maxContains、 /// minPropertiesmaxPropertiesdependentRequired、 -/// dependentSchemas +/// dependentSchemasallOf /// 与稳定字符串 format 子集,让数值步进、数组去重、数组匹配计数、 -/// 对象属性数量、对象内字段依赖以及条件对象子 schema 在运行时与生成器 / 工具侧保持一致。 +/// 对象属性数量、对象内字段依赖、条件对象子 schema 与对象组合约束在运行时与生成器 / 工具侧保持一致。 /// -internal static class YamlConfigSchemaValidator +internal static partial class YamlConfigSchemaValidator { // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. @@ -325,6 +325,16 @@ internal static class YamlConfigSchemaValidator var typeName = typeElement.GetString() ?? string.Empty; var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); + if (!string.Equals(typeName, "object", StringComparison.Ordinal) && + element.TryGetProperty("allOf", out _)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' can only declare 'allOf' on object schemas.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } var parsedNode = typeName switch { @@ -825,7 +835,8 @@ internal static class YamlConfigSchemaValidator /// 当前对象已出现的属性集合。 /// 对象 schema 节点。 /// - /// 可选的跨表引用收集器;当 dependentSchemas 命中且匹配成功时,只会回写该条件分支新增的引用。 + /// 可选的跨表引用收集器;当 dependentSchemasallOf 命中且匹配成功时, + /// 只会回写对应内联分支新增的引用。 /// private static void ValidateObjectConstraints( string tableName, @@ -912,47 +923,81 @@ internal static class YamlConfigSchemaValidator } } - if (constraints.DependentSchemas is null || - constraints.DependentSchemas.Count == 0) + if (constraints.DependentSchemas is not null && + constraints.DependentSchemas.Count > 0) + { + foreach (var dependency in constraints.DependentSchemas) + { + if (!seenProperties.Contains(dependency.Key)) + { + continue; + } + + var triggerPath = CombineDisplayPath(displayPath, dependency.Key); + // dependentSchemas acts as an additional conditional constraint block on the + // current object. Keep undeclared sibling fields outside the dependent sub-schema + // from blocking the match so schema authors can express focused follow-up rules. + // The trial matcher merges only new reference usages back into the outer collector, + // so re-checking the same scalar via a conditional sub-schema does not duplicate + // cross-table validation work later in the loader pipeline. + if (TryMatchSchemaNode( + tableName, + yamlPath, + displayPath, + mappingNode, + dependency.Value, + references, + allowUnknownObjectProperties: true)) + { + continue; + } + + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + detail: + $"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema."); + } + } + + if (constraints.AllOfSchemas is null || + constraints.AllOfSchemas.Count == 0) { return; } - foreach (var dependency in constraints.DependentSchemas) + for (var index = 0; index < constraints.AllOfSchemas.Count; index++) { - if (!seenProperties.Contains(dependency.Key)) - { - continue; - } - - var triggerPath = CombineDisplayPath(displayPath, dependency.Key); - // dependentSchemas acts as an additional conditional constraint block on the - // current object. Keep undeclared sibling fields outside the dependent sub-schema - // from blocking the match so schema authors can express focused follow-up rules. - // The trial matcher merges only new reference usages back into the outer collector, - // so re-checking the same scalar via a conditional sub-schema does not duplicate - // cross-table validation work later in the loader pipeline. + var allOfSchema = constraints.AllOfSchemas[index]; + // allOf follows the same focused constraint block semantics as dependentSchemas: + // the inline schema may validate a subset of the current object without forcing + // unrelated sibling fields to be restated. if (TryMatchSchemaNode( tableName, yamlPath, displayPath, mappingNode, - dependency.Value, + allOfSchema, references, allowUnknownObjectProperties: true)) { continue; } + var allOfEntryNumber = index + 1; throw ConfigLoadExceptionFactory.Create( ConfigLoadFailureKind.ConstraintViolation, tableName, - $"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.", + $"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.", yamlPath: yamlPath, schemaPath: schemaNode.SchemaPathHint, displayPath: GetDiagnosticPath(displayPath), detail: - $"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema."); + $"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object."); } } @@ -1591,242 +1636,6 @@ internal static class YamlConfigSchemaValidator return new YamlConfigArrayContainsConstraints(containsNode, minContains, maxContains); } - /// - /// 解析对象节点支持的属性数量约束。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 对象约束模型;未声明时返回空。 - private static YamlConfigObjectConstraints? ParseObjectConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - var minProperties = TryParseObjectPropertyCountConstraint( - tableName, - schemaPath, - propertyPath, - element, - "minProperties"); - var maxProperties = TryParseObjectPropertyCountConstraint( - tableName, - schemaPath, - propertyPath, - element, - "maxProperties"); - var dependentRequired = ParseDependentRequiredConstraints(tableName, schemaPath, propertyPath, element, properties); - var dependentSchemas = ParseDependentSchemasConstraints(tableName, schemaPath, propertyPath, element, properties); - - if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) - { - var targetDescription = DescribeObjectSchemaTarget(propertyPath); - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{targetDescription} in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - return !minProperties.HasValue && !maxProperties.HasValue && dependentRequired is null && dependentSchemas is null - ? null - : new YamlConfigObjectConstraints(minProperties, maxProperties, dependentRequired, dependentSchemas); - } - - /// - /// 解析对象节点声明的 dependentRequired 依赖关系。 - /// 该关键字只表达“当触发字段出现时,还必须同时声明哪些同级字段”, - /// 因此这里会把触发字段与依赖字段都限制在当前对象已声明的属性集合内, - /// 避免运行时与工具链对无效键名各自做隐式容错。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 归一化后的依赖关系表;未声明或只有空依赖时返回空。 - private static IReadOnlyDictionary>? ParseDependentRequiredConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - if (!element.TryGetProperty("dependentRequired", out var dependentRequiredElement)) - { - return null; - } - - if (dependentRequiredElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentRequired' as an object.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependentRequired = new Dictionary>(StringComparer.Ordinal); - foreach (var dependency in dependentRequiredElement.EnumerateObject()) - { - if (!properties.ContainsKey(dependency.Name)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' for undeclared property '{dependency.Name}'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (dependency.Value.ValueKind != JsonValueKind.Array) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' as an array of sibling property names.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencyTargets = new List(); - var seenDependencyTargets = new HashSet(StringComparer.Ordinal); - foreach (var dependencyTarget in dependency.Value.EnumerateArray()) - { - if (dependencyTarget.ValueKind != JsonValueKind.String) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentRequired' entries as strings.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencyTargetName = dependencyTarget.GetString(); - if (string.IsNullOrWhiteSpace(dependencyTargetName)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' cannot declare blank 'dependentRequired' entries.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (!properties.ContainsKey(dependencyTargetName)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentRequired' target '{dependencyTargetName}' that is not declared in the same object schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (seenDependencyTargets.Add(dependencyTargetName)) - { - dependencyTargets.Add(dependencyTargetName); - } - } - - if (dependencyTargets.Count > 0) - { - dependentRequired[dependency.Name] = dependencyTargets; - } - } - - return dependentRequired.Count == 0 - ? null - : dependentRequired; - } - - /// - /// 解析对象节点声明的 dependentSchemas 条件 schema。 - /// 当前实现把它作为“当触发字段出现时,当前对象还必须额外满足一段内联 schema”来解释, - /// 因此触发字段仍限制在当前对象已声明的属性内,而具体约束则继续复用现有递归节点解析逻辑。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 当前对象已声明的属性集合。 - /// 归一化后的触发字段到条件 schema 的映射;未声明时返回空。 - private static IReadOnlyDictionary? ParseDependentSchemasConstraints( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - IReadOnlyDictionary properties) - { - if (!element.TryGetProperty("dependentSchemas", out var dependentSchemasElement)) - { - return null; - } - - if (dependentSchemasElement.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' must declare 'dependentSchemas' as an object.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependentSchemas = new Dictionary(StringComparer.Ordinal); - foreach (var dependency in dependentSchemasElement.EnumerateObject()) - { - if (!properties.ContainsKey(dependency.Name)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{DescribeObjectSchemaTarget(propertyPath)} in schema file '{schemaPath}' declares 'dependentSchemas' for undeclared property '{dependency.Name}'.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - if (dependency.Value.ValueKind != JsonValueKind.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'dependentSchemas' as an object-valued schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - var dependencySchemaPath = BuildNestedSchemaPath(propertyPath, $"dependentSchemas:{dependency.Name}"); - var dependencySchemaNode = ParseNode( - tableName, - schemaPath, - dependencySchemaPath, - dependency.Value); - if (dependencySchemaNode.NodeType != YamlConfigSchemaPropertyType.Object) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"Property '{dependency.Name}' in {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare an object-typed 'dependentSchemas' schema.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(dependencySchemaPath)); - } - - dependentSchemas[dependency.Name] = dependencySchemaNode; - } - - return dependentSchemas.Count == 0 - ? null - : dependentSchemas; - } - /// /// 读取数值区间约束。 /// @@ -2162,69 +1971,6 @@ internal static class YamlConfigSchemaValidator return constraintValue; } - /// - /// 读取对象属性数量约束。 - /// - /// 所属配置表名称。 - /// Schema 文件路径。 - /// 对象字段路径。 - /// Schema 节点。 - /// 关键字名称。 - /// 属性数量约束;未声明时返回空。 - private static int? TryParseObjectPropertyCountConstraint( - string tableName, - string schemaPath, - string propertyPath, - JsonElement element, - string keywordName) - { - if (!element.TryGetProperty(keywordName, out var constraintElement)) - { - return null; - } - - if (constraintElement.ValueKind != JsonValueKind.Number || - !constraintElement.TryGetInt32(out var constraintValue) || - constraintValue < 0) - { - var targetDescription = DescribeObjectSchemaTarget(propertyPath); - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.SchemaUnsupported, - tableName, - $"{targetDescription} in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", - schemaPath: schemaPath, - displayPath: GetDiagnosticPath(propertyPath)); - } - - return constraintValue; - } - - /// - /// 为对象级 schema 关键字构造稳定的诊断主体。 - /// 根对象不会再显示为空字符串属性名,避免坏 schema 诊断出现 Property '' 之类的文本。 - /// - /// 对象字段路径。 - /// 用于错误消息的对象主体描述。 - private static string DescribeObjectSchemaTarget(string propertyPath) - { - return string.IsNullOrWhiteSpace(propertyPath) - ? "Root object" - : $"Property '{propertyPath}'"; - } - - /// - /// 为插入句中位置的对象级 schema 关键字构造稳定描述。 - /// 这里只调整语法前缀大小写,不改变真实字段路径,避免诊断消息把 schema 作者声明的大小写一起改写。 - /// - /// 对象字段路径。 - /// 可直接拼接到句中介词后的对象主体描述。 - private static string DescribeObjectSchemaTargetInClause(string propertyPath) - { - return string.IsNullOrWhiteSpace(propertyPath) - ? "root object" - : $"property '{propertyPath}'"; - } - /// /// 读取数组去重约束。 /// @@ -3624,6 +3370,15 @@ internal static class YamlConfigSchemaValidator CollectReferencedTableNames(dependentSchemaNode, referencedTableNames); } } + + var allOfSchemas = node.ObjectConstraints?.AllOfSchemas; + if (allOfSchemas is not null) + { + foreach (var allOfSchemaNode in allOfSchemas) + { + CollectReferencedTableNames(allOfSchemaNode, referencedTableNames); + } + } } /// @@ -4217,7 +3972,7 @@ internal sealed class YamlConfigAllowedValue } /// -/// 表示一个对象节点上声明的属性数量约束、字段依赖约束与条件子 schema。 +/// 表示一个对象节点上声明的属性数量约束、字段依赖约束、条件子 schema 与组合约束。 /// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 /// internal sealed class YamlConfigObjectConstraints @@ -4229,16 +3984,19 @@ internal sealed class YamlConfigObjectConstraints /// 最大属性数量约束。 /// 对象内字段依赖约束。 /// 对象内条件 schema 约束。 + /// 对象内组合 schema 约束。 public YamlConfigObjectConstraints( int? minProperties, int? maxProperties, IReadOnlyDictionary>? dependentRequired, - IReadOnlyDictionary? dependentSchemas) + IReadOnlyDictionary? dependentSchemas, + IReadOnlyList? allOfSchemas) { MinProperties = minProperties; MaxProperties = maxProperties; DependentRequired = dependentRequired; DependentSchemas = dependentSchemas; + AllOfSchemas = allOfSchemas; } /// @@ -4262,6 +4020,12 @@ internal sealed class YamlConfigObjectConstraints /// 键表示“触发字段”,值表示“触发字段出现后当前对象还必须满足的额外 schema 子树”。 /// public IReadOnlyDictionary? DependentSchemas { get; } + + /// + /// 获取对象内 allOf 组合约束。 + /// 每个条目都表示“当前对象还必须额外满足的 focused constraint block”。 + /// + public IReadOnlyList? AllOfSchemas { get; } } /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 5229394e..88442e3c 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -690,6 +690,583 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证对象 allOf 会写入生成 XML 文档。 + /// + [Test] + public void Run_Should_Write_AllOf_Constraint_Into_Generated_Documentation() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var generatedSources = result.Results + .Single() + .GeneratedSources + .ToDictionary( + static sourceResult => sourceResult.HintName, + static sourceResult => sourceResult.SourceText.ToString(), + StringComparer.Ordinal); + + Assert.That(result.Results.Single().Diagnostics, Is.Empty); + Assert.That( + generatedSources["MonsterConfig.g.cs"], + Does.Contain("Constraints: allOf = [ object (required = [itemCount]) ].")); + } + + /// + /// 验证只有 object 节点允许声明 allOf。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_NonObject_Schema_Declares_AllOf() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "tag"], + "properties": { + "id": { "type": "integer" }, + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("tag")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only object schemas can declare 'allOf'.")); + }); + } + + /// + /// 验证生成器会拒绝非数组的 allOf 声明。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Is_Not_An_Array() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("The 'allOf' value must be an array.")); + }); + } + + /// + /// 验证生成器会拒绝非对象值的 allOf 条目。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Is_Not_Object_Valued() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [123] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Entry #1 in 'allOf' must be an object-valued schema.")); + }); + } + + /// + /// 验证生成器会拒绝非 object-typed 的 allOf 条目。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Is_Not_Object_Typed() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "string", + "const": "potion" + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Entry #1 in 'allOf' must declare an object-typed schema.")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.properties 声明为非对象映射。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Properties_Is_Not_Object_Valued() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "properties": 1 + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'properties' as an object-valued map")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.required 声明为非数组。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Is_Not_An_Array() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": {} + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'required' as an array of parent property names")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.required 条目声明为非字符串。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Item_Is_Not_A_String() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": [1] + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'required' entries as parent property-name strings")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.required 条目声明为空白字段名。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Item_Is_Blank() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": [""] + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("cannot declare blank property names in 'required'")); + }); + } + + /// + /// 验证生成器会拒绝在 allOf 中引入父对象未声明的字段。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Targets_Undeclared_Parent_Property() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["bonus"] + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("requires property 'bonus'")); + Assert.That(diagnostic.GetMessage(), Does.Contain("parent object schema")); + }); + } + + /// + /// 验证 allOf 内层递归诊断路径会与运行时保持一致。 + /// + [Test] + public void Run_Should_Report_Diagnostic_With_Runtime_Aligned_Path_When_AllOf_Inner_Schema_Is_Invalid() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "format": "uuid" + } + } + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_009")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]].itemCount")); + Assert.That(diagnostic.GetMessage(), Does.Contain("Only 'string' properties can declare 'format'.")); + }); + } + /// /// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 0cd514a2..1879854c 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas` +- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -726,6 +726,7 @@ var loader = new YamlConfigLoader("config-root") - 对象字段违反 `minProperties` / `maxProperties` - 对象字段违反 `dependentRequired` - 对象字段违反 `dependentSchemas` +- 对象字段违反 `allOf` - 标量 / 对象 / 数组字段违反 `const` - 标量 / 对象 / 数组字段命中 `not` - 标量 / 对象 / 数组字段违反 `enum` @@ -792,6 +793,47 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束 - `dependentRequired`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只表达“当对象内某个字段出现时,还必须同时声明哪些同级字段”,不会改变生成类型形状 - `dependentSchemas`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状,不改变生成类型形状,并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在 +- `allOf`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema 数组,并按 focused constraint block 语义把每个条目叠加到当前对象上,不做属性合并,也不改变生成类型形状 + +`allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。 + +```json +{ + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"] + }, + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + } + ] + } + } +} +``` + +```yaml +reward: + itemId: potion + itemCount: 3 +``` + +兼容性说明:如果你以前按标准 JSON Schema `allOf` 的直觉,把新字段只写进 `allOf` 条目的 `properties` 或 `required`,当前实现不会做属性合并,这类 schema 现在会在加载 / 生成 / 工具解析阶段直接被拒绝。请先把字段提升到父对象的 `properties`,再在 `allOf` 里补充 required 或约束。 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 @@ -888,7 +930,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 +- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index fcbe8e9e..ce6426e2 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1125,6 +1125,10 @@ function parseSchemaNode(rawNode, displayPath) { : undefined }; + if (value.allOf !== undefined && type !== "object") { + throw new Error(`Only object schemas can declare 'allOf' at '${displayPath}'.`); + } + if (type === "object") { const required = Array.isArray(value.required) ? value.required.filter((item) => typeof item === "string") @@ -1135,6 +1139,7 @@ function parseSchemaNode(rawNode, displayPath) { } const dependentRequired = parseDependentRequiredMetadata(value.dependentRequired, displayPath, properties); const dependentSchemas = parseDependentSchemasMetadata(value.dependentSchemas, displayPath, properties); + const allOf = parseAllOfSchemaNodes(value.allOf, displayPath, properties); return applyEnumMetadata(applyConstMetadata({ type: "object", @@ -1145,6 +1150,7 @@ function parseSchemaNode(rawNode, displayPath) { maxProperties: metadata.maxProperties, dependentRequired, dependentSchemas, + allOf, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, @@ -1374,6 +1380,94 @@ function parseDependentSchemasMetadata(rawDependentSchemas, displayPath, propert : undefined; } +/** + * Parse one object-level `allOf` list and keep it aligned with the runtime's + * focused-constraint-block contract. + * + * @param {unknown} rawAllOf Raw `allOf` node. + * @param {string} displayPath Parent schema path. + * @param {Record} properties Declared object properties. + * @returns {SchemaNode[] | undefined} Normalized allOf schema list. + */ +function parseAllOfSchemaNodes(rawAllOf, displayPath, properties) { + if (rawAllOf === undefined) { + return undefined; + } + + if (!Array.isArray(rawAllOf)) { + throw new Error(`Schema property '${displayPath}' must declare 'allOf' as an array.`); + } + + const normalized = []; + for (let index = 0; index < rawAllOf.length; index += 1) { + const rawAllOfSchema = rawAllOf[index]; + if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { + throw new Error( + `Schema property '${displayPath}' must declare 'allOf' entry #${index + 1} as an object-valued schema.`); + } + + if (rawAllOfSchema.type !== "object") { + throw new Error( + `Schema property '${displayPath}' must declare object-typed schemas in 'allOf' entry #${index + 1}.`); + } + + validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties); + const allOfSchema = parseSchemaNode(rawAllOfSchema, `${displayPath}[allOf[${index}]]`); + normalized.push(allOfSchema); + } + + return normalized.length > 0 + ? normalized + : undefined; +} + +/** + * Ensure one object-focused `allOf` entry only constrains properties that the + * parent object schema already declared. + * + * @param {unknown} rawAllOfSchema Raw allOf entry. + * @param {string} displayPath Parent schema path. + * @param {number} index Zero-based allOf entry index. + * @param {Record} properties Declared parent properties. + */ +function validateAllOfEntryTargets(rawAllOfSchema, displayPath, index, properties) { + if (!rawAllOfSchema || typeof rawAllOfSchema !== "object" || Array.isArray(rawAllOfSchema)) { + return; + } + + if (rawAllOfSchema.properties && + typeof rawAllOfSchema.properties === "object" && + !Array.isArray(rawAllOfSchema.properties)) { + for (const propertyName of Object.keys(rawAllOfSchema.properties)) { + if (Object.prototype.hasOwnProperty.call(properties, propertyName)) { + continue; + } + + throw new Error( + `Schema property '${displayPath}' declares property '${propertyName}' in 'allOf' entry #${index + 1}, ` + + "but that property is not declared in the parent object schema."); + } + } + + if (!Array.isArray(rawAllOfSchema.required)) { + return; + } + + for (const requiredProperty of rawAllOfSchema.required) { + if (typeof requiredProperty !== "string" || requiredProperty.trim().length === 0) { + continue; + } + + if (Object.prototype.hasOwnProperty.call(properties, requiredProperty)) { + continue; + } + + throw new Error( + `Schema property '${displayPath}' requires property '${requiredProperty}' in 'allOf' entry #${index + 1}, ` + + "but that property is not declared in the parent object schema."); + } +} + /** * Validate one schema node against one YAML node. * @@ -1767,6 +1861,32 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca } } + if (Array.isArray(schemaNode.allOf)) { + for (let index = 0; index < schemaNode.allOf.length; index += 1) { + if (matchesSchemaNode(schemaNode.allOf[index], yamlNode, true)) { + continue; + } + + const localizedMessage = localizeValidationMessage( + ValidationMessageKeys.allOfViolation, + localizer, + { + displayPath: displayPath || "", + index: String(index + 1) + }); + + if (reportedMessages.has(localizedMessage)) { + continue; + } + + diagnostics.push({ + severity: "error", + message: localizedMessage + }); + reportedMessages.add(localizedMessage); + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { diagnostics.push({ @@ -1900,6 +2020,14 @@ function matchesSchemaNodeInternal(schemaNode, yamlNode, allowUnknownObjectPrope } } + if (Array.isArray(schemaNode.allOf)) { + for (const allOfSchema of schemaNode.allOf) { + if (!matchesSchemaNodeInternal(allOfSchema, yamlNode, true)) { + return false; + } + } + } + if (typeof schemaNode.minProperties === "number" && propertyCount < schemaNode.minProperties) { return false; @@ -2309,6 +2437,8 @@ function localizeValidationMessage(key, localizer, params) { if (localizer && localizer.isChinese) { switch (key) { + case ValidationMessageKeys.allOfViolation: + return `对象“${params.displayPath}”必须满足全部 \`allOf\` schema,第 ${params.index} 项未匹配。`; case ValidationMessageKeys.constMismatch: return `属性“${params.displayPath}”必须匹配固定值 ${params.value}。`; case ValidationMessageKeys.dependentRequiredViolation: @@ -2363,6 +2493,8 @@ function localizeValidationMessage(key, localizer, params) { } switch (key) { + case ValidationMessageKeys.allOfViolation: + return `Object '${params.displayPath}' must satisfy all 'allOf' schemas; entry #${params.index} did not match.`; case ValidationMessageKeys.constMismatch: return `Property '${params.displayPath}' must match constant value ${params.value}.`; case ValidationMessageKeys.dependentRequiredViolation: @@ -3119,6 +3251,7 @@ module.exports = { * maxProperties?: number, * dependentRequired?: Record, * dependentSchemas?: Record, + * allOf?: SchemaNode[], * title?: string, * description?: string, * defaultValue?: string, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 3c7266d3..51453727 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1622,7 +1622,7 @@ function describeInlineSchemaForHint(schema, includeRequiredProperties = false) /** * Render human-facing metadata hints for one schema field. * - * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record, dependentSchemas?: Record, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string, minItems?: number, maxItems?: number, minContains?: number, maxContains?: number, minProperties?: number, maxProperties?: number, required?: string[], dependentRequired?: Record, dependentSchemas?: Record, allOf?: Array<{type?: string, required?: string[], enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}>, uniqueItems?: boolean, enumValues?: string[], contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, format?: string, refTable?: string}, items?: {enumValues?: string[], constValue?: string, constDisplayValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, format?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @param {boolean} includeDescription Whether description text should be included in the hint output. * @returns {string} HTML fragment. @@ -1723,6 +1723,15 @@ function renderFieldHint(propertySchema, isArrayField, includeDescription = true } } + if (propertySchema.type === "object" && + Array.isArray(propertySchema.allOf)) { + for (const allOfSchema of propertySchema.allOf) { + hints.push(escapeHtml(localizer.t("webview.hint.allOf", { + schema: describeInlineSchemaForHint(allOfSchema, true) + }))); + } + } + if (isArrayField && typeof propertySchema.minItems === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 9a60d215..099e41f7 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -136,11 +136,13 @@ const enMessages = { "webview.hint.maxProperties": "Max properties: {value}", "webview.hint.dependentRequired": "When {trigger} is set: require {dependencies}", "webview.hint.dependentSchemas": "When {trigger} is set: satisfy {schema}", + "webview.hint.allOf": "Also satisfy: {schema}", "webview.hint.refTable": "Ref table: {refTable}", "webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.", "webview.unsupported.type": "{type} fields are currently raw-YAML-only.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [ValidationMessageKeys.allOfViolation]: "Object '{displayPath}' must satisfy all 'allOf' schemas; entry #{index} did not match.", [ValidationMessageKeys.constMismatch]: "Property '{displayPath}' must match constant value {value}.", [ValidationMessageKeys.dependentRequiredViolation]: "Property '{displayPath}' is required when sibling property '{triggerProperty}' is present.", [ValidationMessageKeys.dependentSchemasViolation]: "Object '{displayPath}' must satisfy the dependent schema triggered by sibling property '{triggerProperty}'.", @@ -260,11 +262,13 @@ const zhCnMessages = { "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}", "webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}", + "webview.hint.allOf": "还必须满足:{schema}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.allOfViolation]: "对象“{displayPath}”必须满足全部 `allOf` schema,第 {index} 项未匹配。", [ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。", [ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。", [ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 8ea4cd99..407baf3a 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,4 +1,5 @@ const ValidationMessageKeys = Object.freeze({ + allOfViolation: "validation.allOfViolation", constMismatch: "validation.constMismatch", dependentSchemasViolation: "validation.dependentSchemasViolation", enumMismatch: "validation.enumMismatch", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 091a813a..95623d68 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1792,6 +1792,162 @@ test("parseSchemaContent should reject non-object-typed dependentSchemas sub-sch ); }); +test("parseSchemaContent should capture allOf metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `); + + assert.equal(schema.properties.reward.allOf[0].type, "object"); + assert.deepEqual(schema.properties.reward.allOf[0].required, ["itemCount"]); +}); + +test("parseSchemaContent should reject allOf declarations on non-object schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "allOf": [ + { + "type": "object", + "properties": {} + } + ] + } + } + } + `), + /Only object schemas can declare 'allOf'/u + ); +}); + +test("parseSchemaContent should reject non-array allOf declarations", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + } + } + } + } + } + `), + /must declare 'allOf' as an array/u + ); +}); + +test("parseSchemaContent should reject non-object-typed allOf sub-schemas", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "string", + "const": "potion" + } + ] + } + } + } + `), + /object-typed schemas in 'allOf' entry #1/u + ); +}); + +test("parseSchemaContent should reject allOf entries that introduce undeclared parent properties", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["bonus"] + } + ] + } + } + } + `), + /requires property 'bonus' in 'allOf' entry #1/u + ); +}); + +test("parseSchemaContent should use runtime-aligned allOf paths for nested schema errors", () => { + assert.throws( + () => parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "format": "uuid" + } + } + } + ] + } + } + } + `), + /reward\[allOf\[0\]\]\.itemCount/u + ); +}); + test("parseSchemaContent should capture not sub-schema metadata", () => { const schema = parseSchemaContent(` { @@ -1942,6 +2098,87 @@ reward: assert.deepEqual(validateParsedConfig(schema, yaml), []); }); +test("validateParsedConfig should report allOf violations", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + } + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + itemId: potion +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].severity, "error"); + assert.match(diagnostics[0].message, /allOf/u); + assert.match(diagnostics[0].message, /#1|第 1 项/u); +}); + +test("validateParsedConfig should accept satisfied allOf constraints", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "properties": { + "itemId": { "type": "string" }, + "itemCount": { "type": "integer" }, + "bonus": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": ["itemCount"], + "properties": { + "itemCount": { "type": "integer" } + } + }, + { + "type": "object", + "properties": { + "itemCount": { + "type": "integer", + "minimum": 2 + } + } + } + ] + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + itemId: potion + itemCount: 3 + bonus: 1 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("parseSchemaContent should reject non-object not declarations", () => { assert.throws( () => parseSchemaContent(` diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index c3dd07f4..e3969560 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -146,3 +146,31 @@ test("createLocalizer should expose dependentSchemas validation keys", () => { }), "对象“reward”在属性“reward.itemId”存在时,必须满足对应的 dependent schema。"); }); + +test("createLocalizer should expose allOf validation keys", () => { + const englishLocalizer = createLocalizer("en"); + const chineseLocalizer = createLocalizer("zh-cn"); + + assert.equal( + englishLocalizer.t("webview.hint.allOf", { + schema: "object, Required: itemCount" + }), + "Also satisfy: object, Required: itemCount"); + assert.equal( + chineseLocalizer.t("webview.hint.allOf", { + schema: "object, 必填字段:itemCount" + }), + "还必须满足:object, 必填字段:itemCount"); + assert.equal( + englishLocalizer.t(ValidationMessageKeys.allOfViolation, { + displayPath: "reward", + index: "1" + }), + "Object 'reward' must satisfy all 'allOf' schemas; entry #1 did not match."); + assert.equal( + chineseLocalizer.t(ValidationMessageKeys.allOfViolation, { + displayPath: "reward", + index: "1" + }), + "对象“reward”必须满足全部 `allOf` schema,第 1 项未匹配。"); +});