feat(generator): 添加JSON schema配置生成器

- 实现了基于JSON schema自动生成配置类型和配置表包装的功能
- 支持嵌套对象、对象数组、标量数组的数据结构生成
- 添加了default/enum/const/ref-table元数据映射功能
- 实现了查找索引生成功能,支持唯一键快速检索
- 集成了诊断报告系统,提供详细的错误提示信息
- 生成配置类、表类和绑定类三种类型的源代码
- 支持日期、时间、邮箱等字符串格式验证功能
- 实现了依赖关系验证,确保schema间的引用正确性
This commit is contained in:
GeWuYou 2026-04-17 16:18:14 +08:00
parent 66e1f06f2f
commit faa0143799
4 changed files with 230 additions and 8 deletions

View File

@ -1357,9 +1357,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
diagnostic = null; diagnostic = null;
var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex); var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex);
if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement) && if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement))
allOfPropertiesElement.ValueKind == JsonValueKind.Object)
{ {
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()) foreach (var property in allOfPropertiesElement.EnumerateObject())
{ {
if (declaredProperties.Contains(property.Name)) if (declaredProperties.Contains(property.Name))
@ -1377,12 +1387,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
} }
if (!allOfSchema.TryGetProperty("required", out var requiredElement) || if (!allOfSchema.TryGetProperty("required", out var requiredElement))
requiredElement.ValueKind != JsonValueKind.Array)
{ {
return true; 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()) foreach (var requiredProperty in requiredElement.EnumerateArray())
{ {
if (requiredProperty.ValueKind != JsonValueKind.String) if (requiredProperty.ValueKind != JsonValueKind.String)

View File

@ -300,6 +300,86 @@ public sealed class YamlConfigLoaderAllOfTests
}); });
} }
/// <summary>
/// 验证 allOf 条目的 <c>properties</c> 必须声明为对象映射。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 验证 allOf 条目的 <c>required</c> 必须声明为字段名数组。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary> /// <summary>
/// 验证 allOf 条目不能要求父对象未声明的字段。 /// 验证 allOf 条目不能要求父对象未声明的字段。
/// </summary> /// </summary>

View File

@ -1975,9 +1975,18 @@ internal static class YamlConfigSchemaValidator
JsonElement allOfSchemaElement, JsonElement allOfSchemaElement,
IReadOnlyDictionary<string, YamlConfigSchemaNode> properties) IReadOnlyDictionary<string, YamlConfigSchemaNode> properties)
{ {
if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement) && if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement))
allOfPropertiesElement.ValueKind == JsonValueKind.Object)
{ {
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()) foreach (var property in allOfPropertiesElement.EnumerateObject())
{ {
if (properties.ContainsKey(property.Name)) if (properties.ContainsKey(property.Name))
@ -1994,12 +2003,21 @@ internal static class YamlConfigSchemaValidator
} }
} }
if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement) || if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement))
allOfRequiredElement.ValueKind != JsonValueKind.Array)
{ {
return; 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()) foreach (var requiredProperty in allOfRequiredElement.EnumerateArray())
{ {
if (requiredProperty.ValueKind != JsonValueKind.String) if (requiredProperty.ValueKind != JsonValueKind.String)

View File

@ -949,6 +949,110 @@ public class SchemaConfigGeneratorTests
}); });
} }
/// <summary>
/// 验证生成器会拒绝把 <c>allOf.properties</c> 声明为非对象映射。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证生成器会拒绝把 <c>allOf.required</c> 声明为非数组。
/// </summary>
[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"));
});
}
/// <summary> /// <summary>
/// 验证生成器会拒绝在 <c>allOf</c> 中引入父对象未声明的字段。 /// 验证生成器会拒绝在 <c>allOf</c> 中引入父对象未声明的字段。
/// </summary> /// </summary>