mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-12 13:14:30 +08:00
Merge pull request #211 from GeWuYou/feat/yaml-schema-validation
feat(config): 添加YAML配置文件的JSON Schema校验功能
This commit is contained in:
commit
83cceed57b
@ -1,6 +1,5 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using GFramework.Game.Abstractions.Config;
|
||||||
using GFramework.Game.Config;
|
using GFramework.Game.Config;
|
||||||
using GFramework.Game.Config.Generated;
|
using GFramework.Game.Config.Generated;
|
||||||
|
|
||||||
@ -88,7 +87,8 @@ public class GeneratedConfigConsumerIntegrationTests
|
|||||||
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
|
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
|
||||||
Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
|
Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
|
||||||
Assert.That(monsterTable.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
|
Assert.That(monsterTable.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
|
||||||
Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" }));
|
Assert.That(dungeonMonsters.Select(static config => config.Name),
|
||||||
|
Is.EquivalentTo(new[] { "Slime", "Goblin" }));
|
||||||
Assert.That(monsterTable.TryFindFirstByName("Goblin", out var goblin), Is.True);
|
Assert.That(monsterTable.TryFindFirstByName("Goblin", out var goblin), Is.True);
|
||||||
Assert.That(goblin, Is.Not.Null);
|
Assert.That(goblin, Is.Not.Null);
|
||||||
Assert.That(goblin!.Id, Is.EqualTo(2));
|
Assert.That(goblin!.Id, Is.EqualTo(2));
|
||||||
@ -154,10 +154,13 @@ public class GeneratedConfigConsumerIntegrationTests
|
|||||||
Is.EqualTo(new[] { MonsterConfigBindings.TableName }));
|
Is.EqualTo(new[] { MonsterConfigBindings.TableName }));
|
||||||
Assert.That(GeneratedConfigCatalog.GetTablesForRegistration().Select(static metadata => metadata.TableName),
|
Assert.That(GeneratedConfigCatalog.GetTablesForRegistration().Select(static metadata => metadata.TableName),
|
||||||
Is.SupersetOf(new[] { ItemConfigBindings.TableName, MonsterConfigBindings.TableName }));
|
Is.SupersetOf(new[] { ItemConfigBindings.TableName, MonsterConfigBindings.TableName }));
|
||||||
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, monsterOnlyOptions), Is.True);
|
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, monsterOnlyOptions),
|
||||||
|
Is.True);
|
||||||
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, monsterOnlyOptions), Is.False);
|
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, monsterOnlyOptions), Is.False);
|
||||||
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, predicateOnlyOptions), Is.True);
|
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, predicateOnlyOptions),
|
||||||
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, predicateOnlyOptions), Is.False);
|
Is.True);
|
||||||
|
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, predicateOnlyOptions),
|
||||||
|
Is.False);
|
||||||
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, options: null), Is.True);
|
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, options: null), Is.True);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -232,6 +235,66 @@ public class GeneratedConfigConsumerIntegrationTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证生成绑定会同时暴露 YAML 序列化、schema 路径解析与文本校验入口。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task GeneratedBindings_Should_Expose_Serializer_And_Validator_Helpers()
|
||||||
|
{
|
||||||
|
CreateMonsterFiles();
|
||||||
|
|
||||||
|
var config = new MonsterConfig
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
Name = "Bat",
|
||||||
|
Hp = 12,
|
||||||
|
Faction = "cave"
|
||||||
|
};
|
||||||
|
|
||||||
|
var yaml = MonsterConfigBindings.SerializeToYaml(config);
|
||||||
|
var schemaPath = MonsterConfigBindings.GetSchemaPath(_rootPath);
|
||||||
|
var configDirectoryPath = MonsterConfigBindings.GetConfigDirectoryPath(_rootPath);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(schemaPath, Is.EqualTo(Path.Combine(_rootPath, "schemas", "monster.schema.json")));
|
||||||
|
Assert.That(configDirectoryPath, Is.EqualTo(Path.Combine(_rootPath, "monster")));
|
||||||
|
Assert.That(yaml, Does.Contain("id: 3"));
|
||||||
|
Assert.That(yaml, Does.Contain("name: Bat"));
|
||||||
|
Assert.That(yaml, Does.Contain("hp: 12"));
|
||||||
|
Assert.That(yaml, Does.Contain("faction: cave"));
|
||||||
|
Assert.That(yaml.EndsWith("\n", StringComparison.Ordinal), Is.True);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() =>
|
||||||
|
MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", yaml));
|
||||||
|
|
||||||
|
Assert.DoesNotThrowAsync(async () =>
|
||||||
|
await MonsterConfigBindings.ValidateYamlAsync(_rootPath, "monster/generated.yaml", yaml));
|
||||||
|
|
||||||
|
var invalidYaml = """
|
||||||
|
id: 3
|
||||||
|
name: Bat
|
||||||
|
hp: 12
|
||||||
|
unknownField: true
|
||||||
|
""";
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ConfigLoadException>(() =>
|
||||||
|
MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", invalidYaml));
|
||||||
|
var asyncException = Assert.ThrowsAsync<ConfigLoadException>(async () =>
|
||||||
|
await MonsterConfigBindings.ValidateYamlAsync(_rootPath, "monster/generated.yaml", invalidYaml));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
|
||||||
|
Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
|
||||||
|
Assert.That(asyncException, Is.Not.Null);
|
||||||
|
Assert.That(asyncException!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
|
||||||
|
Assert.That(asyncException.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在临时消费者根目录中创建测试文件。
|
/// 在临时消费者根目录中创建测试文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -0,0 +1,93 @@
|
|||||||
|
using System.IO;
|
||||||
|
using GFramework.Game.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Tests.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证内部 schema 解析器会输出稳定且可预期的运行时依赖元数据。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class YamlConfigSchemaValidatorTests
|
||||||
|
{
|
||||||
|
private string _rootPath = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为每个测试准备独立临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.SchemaValidatorTests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理测试临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_rootPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_rootPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 schema 中声明的跨表引用名称会以序数排序形式输出,
|
||||||
|
/// 避免热重载依赖推导与测试快照受哈希集合枚举顺序影响。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Load_Should_Return_Referenced_Table_Names_In_Ordinal_Sorted_Order()
|
||||||
|
{
|
||||||
|
var schemaPath = CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"weaponId": {
|
||||||
|
"type": "string",
|
||||||
|
"x-gframework-ref-table": "weapon"
|
||||||
|
},
|
||||||
|
"allies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"x-gframework-ref-table": "ally"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"itemId": {
|
||||||
|
"type": "string",
|
||||||
|
"x-gframework-ref-table": "item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var schema = YamlConfigSchemaValidator.Load("monster", schemaPath);
|
||||||
|
|
||||||
|
Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在临时目录中创建 schema 文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relativePath">相对根目录的路径。</param>
|
||||||
|
/// <param name="content">文件内容。</param>
|
||||||
|
/// <returns>写入后的绝对路径。</returns>
|
||||||
|
private string CreateSchemaFile(
|
||||||
|
string relativePath,
|
||||||
|
string content)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
var directoryPath = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(fullPath, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
using GFramework.Game.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Tests.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证公开 YAML 文本序列化入口的换行与参数契约。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class YamlConfigTextSerializerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证序列化结果会稳定地以 LF 作为尾随换行,
|
||||||
|
/// 避免不同宿主平台的行尾约定影响生成内容。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Serialize_Should_Use_Trailing_Lf_Newline()
|
||||||
|
{
|
||||||
|
var yaml = YamlConfigTextSerializer.Serialize(new MonsterYamlStub
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Slime"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(yaml, Does.Contain("id: 1"));
|
||||||
|
Assert.That(yaml, Does.Contain("name: Slime"));
|
||||||
|
Assert.That(yaml.EndsWith("\n", StringComparison.Ordinal), Is.True);
|
||||||
|
Assert.That(yaml.EndsWith("\r\n", StringComparison.Ordinal), Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证空对象引用会继续通过参数异常暴露给调用方。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Serialize_Should_Throw_When_Value_Is_Null()
|
||||||
|
{
|
||||||
|
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
YamlConfigTextSerializer.Serialize<MonsterYamlStub>(null!));
|
||||||
|
|
||||||
|
Assert.That(exception!.ParamName, Is.EqualTo("value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于 YAML 序列化测试的最小配置对象。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MonsterYamlStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置配置标识。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置配置名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
Normal file
220
GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
using System.IO;
|
||||||
|
using GFramework.Game.Abstractions.Config;
|
||||||
|
using GFramework.Game.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Tests.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证公开的 YAML 文本校验入口可以在保存前复用运行时同一套 schema 规则。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class YamlConfigTextValidatorTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 为每个测试准备独立临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.TextValidatorTests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理测试临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_rootPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_rootPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _rootPath = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证合法 YAML 文本会通过公开校验入口。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Validate_Should_Succeed_When_Yaml_Matches_Schema()
|
||||||
|
{
|
||||||
|
var schemaPath = CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"hp": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() =>
|
||||||
|
YamlConfigTextValidator.Validate(
|
||||||
|
"monster",
|
||||||
|
schemaPath,
|
||||||
|
"monster/generated.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证结构错误会继续通过稳定的配置异常类型暴露给宿主。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Validate_Should_Throw_ConfigLoadException_When_Yaml_Contains_Unknown_Field()
|
||||||
|
{
|
||||||
|
var schemaPath = CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ConfigLoadException>(() =>
|
||||||
|
YamlConfigTextValidator.Validate(
|
||||||
|
"monster",
|
||||||
|
schemaPath,
|
||||||
|
"monster/generated.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Diagnostic.TableName, Is.EqualTo("monster"));
|
||||||
|
Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
|
||||||
|
Assert.That(exception.Diagnostic.YamlPath, Is.EqualTo("monster/generated.yaml"));
|
||||||
|
Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证异步入口与同步入口共享相同校验语义。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ValidateAsync_Should_Throw_ConfigLoadException_When_Required_Field_Is_Missing()
|
||||||
|
{
|
||||||
|
var schemaPath = CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () =>
|
||||||
|
await YamlConfigTextValidator.ValidateAsync(
|
||||||
|
"monster",
|
||||||
|
schemaPath,
|
||||||
|
"monster/generated.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.MissingRequiredProperty));
|
||||||
|
Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
|
||||||
|
Assert.That(exception.Diagnostic.YamlPath, Is.EqualTo("monster/generated.yaml"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证公开校验入口会在 schema 文件发生变化后失效旧缓存,
|
||||||
|
/// 避免保存路径持续沿用过期的字段约束。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Validate_Should_Refresh_Cached_Schema_When_File_Timestamp_Changes()
|
||||||
|
{
|
||||||
|
var schemaPath = CreateSchemaFile(
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"hp": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var yaml = """
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
""";
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() =>
|
||||||
|
YamlConfigTextValidator.Validate("monster", schemaPath, "monster/generated.yaml", yaml));
|
||||||
|
|
||||||
|
File.WriteAllText(
|
||||||
|
schemaPath,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".Replace("\n", Environment.NewLine, StringComparison.Ordinal));
|
||||||
|
File.SetLastWriteTimeUtc(schemaPath, new DateTime(2040, 1, 1, 0, 0, 1, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
var exception = Assert.Throws<ConfigLoadException>(() =>
|
||||||
|
YamlConfigTextValidator.Validate("monster", schemaPath, "monster/generated.yaml", yaml));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
|
||||||
|
Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在临时目录中创建 schema 文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relativePath">相对根目录的路径。</param>
|
||||||
|
/// <param name="content">文件内容。</param>
|
||||||
|
/// <returns>写入后的绝对路径。</returns>
|
||||||
|
private string CreateSchemaFile(
|
||||||
|
string relativePath,
|
||||||
|
string content)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
var directoryPath = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(fullPath, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,18 +19,23 @@ internal static class YamlConfigSchemaValidator
|
|||||||
// JS tooling so grouping and backreferences behave consistently across environments.
|
// JS tooling so grouping and backreferences behave consistently across environments.
|
||||||
private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant;
|
private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant;
|
||||||
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', 'uuid'";
|
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', 'uuid'";
|
||||||
|
|
||||||
private static readonly Regex ExactDecimalPattern = new(
|
private static readonly Regex ExactDecimalPattern = new(
|
||||||
@"^(?<sign>[+-]?)(?:(?<integer>\d+)(?:\.(?<fraction>\d*))?|\.(?<fractionOnly>\d+))(?:[eE](?<exponent>[+-]?\d+))?$",
|
@"^(?<sign>[+-]?)(?:(?<integer>\d+)(?:\.(?<fraction>\d*))?|\.(?<fractionOnly>\d+))(?:[eE](?<exponent>[+-]?\d+))?$",
|
||||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
private static readonly Regex SupportedEmailFormatRegex = new(
|
private static readonly Regex SupportedEmailFormatRegex = new(
|
||||||
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
|
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
|
||||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
private static readonly Regex SupportedDateFormatRegex = new(
|
private static readonly Regex SupportedDateFormatRegex = new(
|
||||||
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$",
|
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$",
|
||||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
private static readonly Regex SupportedDateTimeFormatRegex = new(
|
private static readonly Regex SupportedDateTimeFormatRegex = new(
|
||||||
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$",
|
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$",
|
||||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
private static readonly Regex SupportedUriSchemeRegex = new(
|
private static readonly Regex SupportedUriSchemeRegex = new(
|
||||||
@"^[A-Za-z][A-Za-z0-9+\.-]*:",
|
@"^[A-Za-z][A-Za-z0-9+\.-]*:",
|
||||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
@ -84,34 +89,57 @@ internal static class YamlConfigSchemaValidator
|
|||||||
innerException: exception);
|
innerException: exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
return ParseLoadedSchema(tableName, schemaPath, schemaText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从磁盘同步加载并解析一个 JSON Schema 文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||||
|
/// <returns>解析后的 schema 模型。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
|
||||||
|
/// <exception cref="ConfigLoadException">当 schema 文件不存在或内容非法时抛出。</exception>
|
||||||
|
internal static YamlConfigSchema Load(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tableName))
|
||||||
{
|
{
|
||||||
using var document = JsonDocument.Parse(schemaText);
|
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
|
||||||
var root = document.RootElement;
|
|
||||||
var rootNode = ParseNode(tableName, schemaPath, "<root>", root, isRoot: true);
|
|
||||||
if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
|
|
||||||
{
|
|
||||||
throw ConfigLoadExceptionFactory.Create(
|
|
||||||
ConfigLoadFailureKind.SchemaUnsupported,
|
|
||||||
tableName,
|
|
||||||
$"Schema file '{schemaPath}' must declare a root object schema.",
|
|
||||||
schemaPath: schemaPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var referencedTableNames = new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
CollectReferencedTableNames(rootNode, referencedTableNames);
|
|
||||||
|
|
||||||
return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray());
|
|
||||||
}
|
}
|
||||||
catch (JsonException exception)
|
|
||||||
|
if (string.IsNullOrWhiteSpace(schemaPath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(schemaPath))
|
||||||
{
|
{
|
||||||
throw ConfigLoadExceptionFactory.Create(
|
throw ConfigLoadExceptionFactory.Create(
|
||||||
ConfigLoadFailureKind.SchemaInvalidJson,
|
ConfigLoadFailureKind.SchemaFileNotFound,
|
||||||
tableName,
|
tableName,
|
||||||
$"Schema file '{schemaPath}' contains invalid JSON.",
|
$"Schema file '{schemaPath}' was not found.",
|
||||||
|
schemaPath: schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string schemaText;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
schemaText = File.ReadAllText(schemaPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw ConfigLoadExceptionFactory.Create(
|
||||||
|
ConfigLoadFailureKind.SchemaReadFailed,
|
||||||
|
tableName,
|
||||||
|
$"Failed to read schema file '{schemaPath}'.",
|
||||||
schemaPath: schemaPath,
|
schemaPath: schemaPath,
|
||||||
innerException: exception);
|
innerException: exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ParseLoadedSchema(tableName, schemaPath, schemaText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -211,6 +239,53 @@ internal static class YamlConfigSchemaValidator
|
|||||||
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
|
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析已读取到内存中的 schema 文本,并构造运行时最小模型。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件路径,仅用于诊断信息。</param>
|
||||||
|
/// <param name="schemaText">Schema 文本内容。</param>
|
||||||
|
/// <returns>解析后的 schema 模型。</returns>
|
||||||
|
private static YamlConfigSchema ParseLoadedSchema(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath,
|
||||||
|
string schemaText)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(schemaText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var rootNode = ParseNode(tableName, schemaPath, "<root>", root, isRoot: true);
|
||||||
|
if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
|
||||||
|
{
|
||||||
|
throw ConfigLoadExceptionFactory.Create(
|
||||||
|
ConfigLoadFailureKind.SchemaUnsupported,
|
||||||
|
tableName,
|
||||||
|
$"Schema file '{schemaPath}' must declare a root object schema.",
|
||||||
|
schemaPath: schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var referencedTableNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
CollectReferencedTableNames(rootNode, referencedTableNames);
|
||||||
|
// Preserve a deterministic dependency order so hot-reload bookkeeping and tests
|
||||||
|
// do not depend on HashSet enumeration details.
|
||||||
|
var orderedReferencedTableNames = referencedTableNames
|
||||||
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new YamlConfigSchema(schemaPath, rootNode, orderedReferencedTableNames);
|
||||||
|
}
|
||||||
|
catch (JsonException exception)
|
||||||
|
{
|
||||||
|
throw ConfigLoadExceptionFactory.Create(
|
||||||
|
ConfigLoadFailureKind.SchemaInvalidJson,
|
||||||
|
tableName,
|
||||||
|
$"Schema file '{schemaPath}' contains invalid JSON.",
|
||||||
|
schemaPath: schemaPath,
|
||||||
|
innerException: exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
|
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -662,7 +737,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: GetDiagnosticPath(displayPath),
|
displayPath: GetDiagnosticPath(displayPath),
|
||||||
rawValue: rawValue,
|
rawValue: rawValue,
|
||||||
detail: $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
detail:
|
||||||
|
$"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.MaxProperties.HasValue &&
|
if (constraints.MaxProperties.HasValue &&
|
||||||
@ -676,7 +752,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: GetDiagnosticPath(displayPath),
|
displayPath: GetDiagnosticPath(displayPath),
|
||||||
rawValue: rawValue,
|
rawValue: rawValue,
|
||||||
detail: $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
detail:
|
||||||
|
$"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1017,7 +1094,7 @@ internal static class YamlConfigSchemaValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var properties = schemaNode.Properties
|
var properties = schemaNode.Properties
|
||||||
?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
|
?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
|
||||||
var objectEntries = new List<KeyValuePair<string, string>>();
|
var objectEntries = new List<KeyValuePair<string, string>>();
|
||||||
foreach (var property in element.EnumerateObject())
|
foreach (var property in element.EnumerateObject())
|
||||||
{
|
{
|
||||||
@ -1087,19 +1164,18 @@ internal static class YamlConfigSchemaValidator
|
|||||||
return "[" +
|
return "[" +
|
||||||
string.Join(
|
string.Join(
|
||||||
",",
|
",",
|
||||||
element.EnumerateArray().Select(
|
element.EnumerateArray().Select((item, index) =>
|
||||||
(item, index) =>
|
{
|
||||||
{
|
var comparableValue = BuildComparableConstantValue(
|
||||||
var comparableValue = BuildComparableConstantValue(
|
tableName,
|
||||||
tableName,
|
schemaPath,
|
||||||
schemaPath,
|
$"{propertyPath}[{index}]",
|
||||||
$"{propertyPath}[{index}]",
|
keywordName,
|
||||||
keywordName,
|
item,
|
||||||
item,
|
schemaNode.ItemNode);
|
||||||
schemaNode.ItemNode);
|
return
|
||||||
return
|
$"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
|
||||||
$"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
|
})) +
|
||||||
})) +
|
|
||||||
"]";
|
"]";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1133,11 +1209,11 @@ internal static class YamlConfigSchemaValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解析标量字段支持的范围、长度与模式约束。
|
/// 解析标量字段支持的范围、长度与模式约束。
|
||||||
/// 当前共享子集支持:
|
/// 当前共享子集支持:
|
||||||
/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`,
|
/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`,
|
||||||
/// 以及 `string` 上的 `minLength/maxLength/pattern/format`。
|
/// 以及 `string` 上的 `minLength/maxLength/pattern/format`。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tableName">所属配置表名称。</param>
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||||
/// <param name="propertyPath">字段路径。</param>
|
/// <param name="propertyPath">字段路径。</param>
|
||||||
@ -1420,7 +1496,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
JsonElement element,
|
JsonElement element,
|
||||||
YamlConfigSchemaPropertyType nodeType)
|
YamlConfigSchemaPropertyType nodeType)
|
||||||
{
|
{
|
||||||
var multipleOf = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf");
|
var multipleOf =
|
||||||
|
TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf");
|
||||||
if (!multipleOf.HasValue)
|
if (!multipleOf.HasValue)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@ -2060,7 +2137,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: GetDiagnosticPath(displayPath),
|
displayPath: GetDiagnosticPath(displayPath),
|
||||||
rawValue: rawValue,
|
rawValue: rawValue,
|
||||||
detail: $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
|
detail:
|
||||||
|
$"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
|
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
|
||||||
@ -2086,7 +2164,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: GetDiagnosticPath(displayPath),
|
displayPath: GetDiagnosticPath(displayPath),
|
||||||
rawValue: rawValue,
|
rawValue: rawValue,
|
||||||
detail: $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
|
detail:
|
||||||
|
$"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.MultipleOf.HasValue &&
|
if (constraints.MultipleOf.HasValue &&
|
||||||
@ -2100,7 +2179,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
schemaPath: schemaNode.SchemaPathHint,
|
schemaPath: schemaNode.SchemaPathHint,
|
||||||
displayPath: GetDiagnosticPath(displayPath),
|
displayPath: GetDiagnosticPath(displayPath),
|
||||||
rawValue: rawValue,
|
rawValue: rawValue,
|
||||||
detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
|
detail:
|
||||||
|
$"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2534,7 +2614,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure)
|
catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind !=
|
||||||
|
ConfigLoadFailureKind.UnexpectedFailure)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -2577,7 +2658,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var properties = schemaNode.Properties
|
var properties = schemaNode.Properties
|
||||||
?? throw new InvalidOperationException("Validated object nodes must expose declared properties.");
|
?? throw new InvalidOperationException(
|
||||||
|
"Validated object nodes must expose declared properties.");
|
||||||
var objectEntries = new List<KeyValuePair<string, string>>(mappingNode.Children.Count);
|
var objectEntries = new List<KeyValuePair<string, string>>(mappingNode.Children.Count);
|
||||||
foreach (var entry in mappingNode.Children)
|
foreach (var entry in mappingNode.Children)
|
||||||
{
|
{
|
||||||
@ -2619,12 +2701,11 @@ internal static class YamlConfigSchemaValidator
|
|||||||
return "[" +
|
return "[" +
|
||||||
string.Join(
|
string.Join(
|
||||||
",",
|
",",
|
||||||
sequenceNode.Children.Select(
|
sequenceNode.Children.Select(item =>
|
||||||
item =>
|
{
|
||||||
{
|
var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
|
||||||
var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
|
return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
|
||||||
return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
|
})) +
|
||||||
})) +
|
|
||||||
"]";
|
"]";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2644,7 +2725,8 @@ internal static class YamlConfigSchemaValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
|
var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
|
||||||
return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
|
return
|
||||||
|
$"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -3119,87 +3201,6 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
private readonly NodeChildren _children;
|
private readonly NodeChildren _children;
|
||||||
private readonly NodeValidation _validation;
|
private readonly NodeValidation _validation;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建对象节点描述。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="properties">对象属性集合。</param>
|
|
||||||
/// <param name="requiredProperties">对象必填属性集合。</param>
|
|
||||||
/// <param name="objectConstraints">对象属性数量约束。</param>
|
|
||||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
|
||||||
/// <returns>对象节点模型。</returns>
|
|
||||||
public static YamlConfigSchemaNode CreateObject(
|
|
||||||
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
|
||||||
IReadOnlyCollection<string>? requiredProperties,
|
|
||||||
YamlConfigObjectConstraints? objectConstraints,
|
|
||||||
string schemaPathHint)
|
|
||||||
{
|
|
||||||
return new YamlConfigSchemaNode(
|
|
||||||
YamlConfigSchemaPropertyType.Object,
|
|
||||||
new NodeChildren(properties, requiredProperties, itemNode: null),
|
|
||||||
new NodeValidation(
|
|
||||||
referenceTableName: null,
|
|
||||||
allowedValues: null,
|
|
||||||
constraints: null,
|
|
||||||
arrayConstraints: null,
|
|
||||||
objectConstraints,
|
|
||||||
constantValue: null),
|
|
||||||
schemaPathHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建数组节点描述。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="itemNode">数组元素节点。</param>
|
|
||||||
/// <param name="arrayConstraints">数组元素数量约束。</param>
|
|
||||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
|
||||||
/// <returns>数组节点模型。</returns>
|
|
||||||
public static YamlConfigSchemaNode CreateArray(
|
|
||||||
YamlConfigSchemaNode itemNode,
|
|
||||||
YamlConfigArrayConstraints? arrayConstraints,
|
|
||||||
string schemaPathHint)
|
|
||||||
{
|
|
||||||
return new YamlConfigSchemaNode(
|
|
||||||
YamlConfigSchemaPropertyType.Array,
|
|
||||||
new NodeChildren(properties: null, requiredProperties: null, itemNode),
|
|
||||||
new NodeValidation(
|
|
||||||
referenceTableName: null,
|
|
||||||
allowedValues: null,
|
|
||||||
constraints: null,
|
|
||||||
arrayConstraints,
|
|
||||||
objectConstraints: null,
|
|
||||||
constantValue: null),
|
|
||||||
schemaPathHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建标量节点描述。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="nodeType">标量节点类型。</param>
|
|
||||||
/// <param name="referenceTableName">目标引用表名称。</param>
|
|
||||||
/// <param name="allowedValues">标量允许值集合。</param>
|
|
||||||
/// <param name="constraints">标量范围与长度约束。</param>
|
|
||||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
|
||||||
/// <returns>标量节点模型。</returns>
|
|
||||||
public static YamlConfigSchemaNode CreateScalar(
|
|
||||||
YamlConfigSchemaPropertyType nodeType,
|
|
||||||
string? referenceTableName,
|
|
||||||
IReadOnlyCollection<string>? allowedValues,
|
|
||||||
YamlConfigScalarConstraints? constraints,
|
|
||||||
string schemaPathHint)
|
|
||||||
{
|
|
||||||
return new YamlConfigSchemaNode(
|
|
||||||
nodeType,
|
|
||||||
NodeChildren.None,
|
|
||||||
new NodeValidation(
|
|
||||||
referenceTableName,
|
|
||||||
allowedValues,
|
|
||||||
constraints,
|
|
||||||
arrayConstraints: null,
|
|
||||||
objectConstraints: null,
|
|
||||||
constantValue: null),
|
|
||||||
schemaPathHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
private YamlConfigSchemaNode(
|
private YamlConfigSchemaNode(
|
||||||
YamlConfigSchemaPropertyType nodeType,
|
YamlConfigSchemaPropertyType nodeType,
|
||||||
NodeChildren children,
|
NodeChildren children,
|
||||||
@ -3281,6 +3282,87 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string SchemaPathHint { get; }
|
public string SchemaPathHint { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建对象节点描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">对象属性集合。</param>
|
||||||
|
/// <param name="requiredProperties">对象必填属性集合。</param>
|
||||||
|
/// <param name="objectConstraints">对象属性数量约束。</param>
|
||||||
|
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||||
|
/// <returns>对象节点模型。</returns>
|
||||||
|
public static YamlConfigSchemaNode CreateObject(
|
||||||
|
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
||||||
|
IReadOnlyCollection<string>? requiredProperties,
|
||||||
|
YamlConfigObjectConstraints? objectConstraints,
|
||||||
|
string schemaPathHint)
|
||||||
|
{
|
||||||
|
return new YamlConfigSchemaNode(
|
||||||
|
YamlConfigSchemaPropertyType.Object,
|
||||||
|
new NodeChildren(properties, requiredProperties, itemNode: null),
|
||||||
|
new NodeValidation(
|
||||||
|
referenceTableName: null,
|
||||||
|
allowedValues: null,
|
||||||
|
constraints: null,
|
||||||
|
arrayConstraints: null,
|
||||||
|
objectConstraints,
|
||||||
|
constantValue: null),
|
||||||
|
schemaPathHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建数组节点描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemNode">数组元素节点。</param>
|
||||||
|
/// <param name="arrayConstraints">数组元素数量约束。</param>
|
||||||
|
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||||
|
/// <returns>数组节点模型。</returns>
|
||||||
|
public static YamlConfigSchemaNode CreateArray(
|
||||||
|
YamlConfigSchemaNode itemNode,
|
||||||
|
YamlConfigArrayConstraints? arrayConstraints,
|
||||||
|
string schemaPathHint)
|
||||||
|
{
|
||||||
|
return new YamlConfigSchemaNode(
|
||||||
|
YamlConfigSchemaPropertyType.Array,
|
||||||
|
new NodeChildren(properties: null, requiredProperties: null, itemNode),
|
||||||
|
new NodeValidation(
|
||||||
|
referenceTableName: null,
|
||||||
|
allowedValues: null,
|
||||||
|
constraints: null,
|
||||||
|
arrayConstraints,
|
||||||
|
objectConstraints: null,
|
||||||
|
constantValue: null),
|
||||||
|
schemaPathHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建标量节点描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nodeType">标量节点类型。</param>
|
||||||
|
/// <param name="referenceTableName">目标引用表名称。</param>
|
||||||
|
/// <param name="allowedValues">标量允许值集合。</param>
|
||||||
|
/// <param name="constraints">标量范围与长度约束。</param>
|
||||||
|
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||||
|
/// <returns>标量节点模型。</returns>
|
||||||
|
public static YamlConfigSchemaNode CreateScalar(
|
||||||
|
YamlConfigSchemaPropertyType nodeType,
|
||||||
|
string? referenceTableName,
|
||||||
|
IReadOnlyCollection<string>? allowedValues,
|
||||||
|
YamlConfigScalarConstraints? constraints,
|
||||||
|
string schemaPathHint)
|
||||||
|
{
|
||||||
|
return new YamlConfigSchemaNode(
|
||||||
|
nodeType,
|
||||||
|
NodeChildren.None,
|
||||||
|
new NodeValidation(
|
||||||
|
referenceTableName,
|
||||||
|
allowedValues,
|
||||||
|
constraints,
|
||||||
|
arrayConstraints: null,
|
||||||
|
objectConstraints: null,
|
||||||
|
constantValue: null),
|
||||||
|
schemaPathHint);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 基于当前节点复制一个只替换引用表名称的新节点。
|
/// 基于当前节点复制一个只替换引用表名称的新节点。
|
||||||
/// 该方法用于把数组级别的 ref-table 语义挂接到元素节点上。
|
/// 该方法用于把数组级别的 ref-table 语义挂接到元素节点上。
|
||||||
@ -3312,8 +3394,6 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
|
|
||||||
private sealed class NodeChildren
|
private sealed class NodeChildren
|
||||||
{
|
{
|
||||||
public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
|
|
||||||
|
|
||||||
public NodeChildren(
|
public NodeChildren(
|
||||||
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
IReadOnlyDictionary<string, YamlConfigSchemaNode>? properties,
|
||||||
IReadOnlyCollection<string>? requiredProperties,
|
IReadOnlyCollection<string>? requiredProperties,
|
||||||
@ -3324,6 +3404,8 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
ItemNode = itemNode;
|
ItemNode = itemNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, YamlConfigSchemaNode>? Properties { get; }
|
public IReadOnlyDictionary<string, YamlConfigSchemaNode>? Properties { get; }
|
||||||
|
|
||||||
public IReadOnlyCollection<string>? RequiredProperties { get; }
|
public IReadOnlyCollection<string>? RequiredProperties { get; }
|
||||||
@ -3333,14 +3415,6 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
|
|
||||||
private sealed class NodeValidation
|
private sealed class NodeValidation
|
||||||
{
|
{
|
||||||
public static NodeValidation None { get; } = new(
|
|
||||||
referenceTableName: null,
|
|
||||||
allowedValues: null,
|
|
||||||
constraints: null,
|
|
||||||
arrayConstraints: null,
|
|
||||||
objectConstraints: null,
|
|
||||||
constantValue: null);
|
|
||||||
|
|
||||||
public NodeValidation(
|
public NodeValidation(
|
||||||
string? referenceTableName,
|
string? referenceTableName,
|
||||||
IReadOnlyCollection<string>? allowedValues,
|
IReadOnlyCollection<string>? allowedValues,
|
||||||
@ -3357,6 +3431,14 @@ internal sealed class YamlConfigSchemaNode
|
|||||||
ConstantValue = constantValue;
|
ConstantValue = constantValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static NodeValidation None { get; } = new(
|
||||||
|
referenceTableName: null,
|
||||||
|
allowedValues: null,
|
||||||
|
constraints: null,
|
||||||
|
arrayConstraints: null,
|
||||||
|
objectConstraints: null,
|
||||||
|
constantValue: null);
|
||||||
|
|
||||||
public string? ReferenceTableName { get; }
|
public string? ReferenceTableName { get; }
|
||||||
|
|
||||||
public IReadOnlyCollection<string>? AllowedValues { get; }
|
public IReadOnlyCollection<string>? AllowedValues { get; }
|
||||||
@ -3534,8 +3616,8 @@ internal sealed class YamlConfigNumericConstraints
|
|||||||
internal sealed class YamlConfigStringConstraints
|
internal sealed class YamlConfigStringConstraints
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化字符串约束模型。
|
/// 初始化字符串约束模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="minLength">最小长度约束。</param>
|
/// <param name="minLength">最小长度约束。</param>
|
||||||
/// <param name="maxLength">最大长度约束。</param>
|
/// <param name="maxLength">最大长度约束。</param>
|
||||||
/// <param name="pattern">正则模式约束原文。</param>
|
/// <param name="pattern">正则模式约束原文。</param>
|
||||||
|
|||||||
43
GFramework.Game/Config/YamlConfigTextSerializer.cs
Normal file
43
GFramework.Game/Config/YamlConfigTextSerializer.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供可复用的 YAML 文本序列化入口,供生成配置绑定与宿主写回流程共享。
|
||||||
|
/// </summary>
|
||||||
|
public static class YamlConfigTextSerializer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将配置对象序列化为 YAML 文本,并统一以 LF 作为尾随换行。
|
||||||
|
/// 该约定与底层 YamlDotNet 输出保持一致,避免不同操作系统的宿主行尾约定影响生成结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TValue">配置对象类型。</typeparam>
|
||||||
|
/// <param name="value">要序列化的配置对象。</param>
|
||||||
|
/// <returns>带尾随 LF 换行的 YAML 文本。</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="value" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||||
|
public static string Serialize<TValue>(TValue value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
// Build one serializer per call so the helper does not rely on undocumented
|
||||||
|
// cross-thread safety guarantees from YamlDotNet's serializer implementation.
|
||||||
|
var yaml = CreateSerializer().Serialize(value);
|
||||||
|
return yaml.EndsWith('\n')
|
||||||
|
? yaml
|
||||||
|
: $"{yaml}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建与运行时配置绑定共享的 YAML 序列化器。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>复用统一命名与默认值策略的序列化器。</returns>
|
||||||
|
private static ISerializer CreateSerializer()
|
||||||
|
{
|
||||||
|
return new SerializerBuilder()
|
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.DisableAliases()
|
||||||
|
.ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
194
GFramework.Game/Config/YamlConfigTextValidator.cs
Normal file
194
GFramework.Game/Config/YamlConfigTextValidator.cs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
namespace GFramework.Game.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供面向宿主的 YAML 文本校验入口,使保存前校验可以复用运行时同一套 schema 规则。
|
||||||
|
/// </summary>
|
||||||
|
public static class YamlConfigTextValidator
|
||||||
|
{
|
||||||
|
// Cache parsed schemas by table/path plus last write time so save-path validation can
|
||||||
|
// avoid repeated disk IO and JSON parsing while still observing schema edits.
|
||||||
|
private static readonly ConcurrentDictionary<SchemaCacheKey, SchemaCacheEntry> SchemaCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定 schema 文件同步校验 YAML 文本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件绝对路径。</param>
|
||||||
|
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
|
||||||
|
/// <param name="yamlText">待校验的 YAML 文本。</param>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="yamlPath" /> 或 <paramref name="yamlText" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||||
|
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// 同步加载 schema 并立即校验,适合非异步上下文;内部委托 <see cref="YamlConfigSchemaValidator.Validate" /> 执行校验逻辑。
|
||||||
|
/// </remarks>
|
||||||
|
public static void Validate(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath,
|
||||||
|
string yamlPath,
|
||||||
|
string yamlText)
|
||||||
|
{
|
||||||
|
var schema = GetOrLoadSchema(tableName, schemaPath);
|
||||||
|
YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定 schema 文件异步校验 YAML 文本。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件绝对路径。</param>
|
||||||
|
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
|
||||||
|
/// <param name="yamlText">待校验的 YAML 文本。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>表示异步校验操作的任务。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="yamlPath" /> 或 <paramref name="yamlText" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||||
|
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||||
|
/// <exception cref="OperationCanceledException">当 <paramref name="cancellationToken" /> 已被触发时抛出。</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// 异步加载 schema(调用 <see cref="YamlConfigSchemaValidator.LoadAsync" />)后同步执行校验,适合 I/O 密集场景;
|
||||||
|
/// 校验本身不涉及异步操作。
|
||||||
|
/// </remarks>
|
||||||
|
public static async Task ValidateAsync(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath,
|
||||||
|
string yamlPath,
|
||||||
|
string yamlText,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var schema = await GetOrLoadSchemaAsync(tableName, schemaPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取可复用的 schema 模型,必要时从磁盘重新加载。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件绝对路径。</param>
|
||||||
|
/// <returns>与当前 schema 文件内容匹配的已解析模型。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
|
||||||
|
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用或内容非法时抛出。</exception>
|
||||||
|
private static YamlConfigSchema GetOrLoadSchema(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath)
|
||||||
|
{
|
||||||
|
var cacheKey = CreateCacheKey(tableName, schemaPath);
|
||||||
|
if (TryGetCachedSchema(cacheKey, out var cachedSchema))
|
||||||
|
{
|
||||||
|
return cachedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(schemaPath);
|
||||||
|
var schema = YamlConfigSchemaValidator.Load(tableName, schemaPath);
|
||||||
|
CacheSchema(cacheKey, lastWriteTimeUtc, schema);
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 异步获取可复用的 schema 模型,必要时从磁盘重新加载。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件绝对路径。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>与当前 schema 文件内容匹配的已解析模型。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
|
||||||
|
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用或内容非法时抛出。</exception>
|
||||||
|
private static async Task<YamlConfigSchema> GetOrLoadSchemaAsync(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cacheKey = CreateCacheKey(tableName, schemaPath);
|
||||||
|
if (TryGetCachedSchema(cacheKey, out var cachedSchema))
|
||||||
|
{
|
||||||
|
return cachedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(schemaPath);
|
||||||
|
var schema = await YamlConfigSchemaValidator.LoadAsync(tableName, schemaPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
CacheSchema(cacheKey, lastWriteTimeUtc, schema);
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建 schema 缓存键,并提前执行与公开入口一致的参数契约检查。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">所属配置表名称。</param>
|
||||||
|
/// <param name="schemaPath">Schema 文件绝对路径。</param>
|
||||||
|
/// <returns>用于缓存查找的稳定键。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
|
||||||
|
private static SchemaCacheKey CreateCacheKey(
|
||||||
|
string tableName,
|
||||||
|
string schemaPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tableName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(schemaPath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchemaCacheKey(tableName, schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试命中当前 schema 文件版本对应的缓存项。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cacheKey">缓存键。</param>
|
||||||
|
/// <param name="schema">命中的 schema;未命中时为 <see langword="null" />。</param>
|
||||||
|
/// <returns>当缓存项仍与当前文件时间戳一致时返回 <see langword="true" />。</returns>
|
||||||
|
private static bool TryGetCachedSchema(
|
||||||
|
SchemaCacheKey cacheKey,
|
||||||
|
out YamlConfigSchema schema)
|
||||||
|
{
|
||||||
|
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(cacheKey.SchemaPath);
|
||||||
|
if (SchemaCache.TryGetValue(cacheKey, out var cacheEntry) &&
|
||||||
|
cacheEntry.LastWriteTimeUtc == lastWriteTimeUtc)
|
||||||
|
{
|
||||||
|
schema = cacheEntry.Schema;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用读取前捕获的文件时间戳刷新 schema 缓存。
|
||||||
|
/// 这样即使 schema 在读取过程中发生变化,后续访问也会因时间戳变新而重新加载,
|
||||||
|
/// 避免把“旧内容 + 新时间戳”写入缓存。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cacheKey">缓存键。</param>
|
||||||
|
/// <param name="lastWriteTimeUtc">本次读取开始前捕获的 schema 文件修改时间。</param>
|
||||||
|
/// <param name="schema">最新加载的 schema。</param>
|
||||||
|
private static void CacheSchema(
|
||||||
|
SchemaCacheKey cacheKey,
|
||||||
|
DateTime lastWriteTimeUtc,
|
||||||
|
YamlConfigSchema schema)
|
||||||
|
{
|
||||||
|
SchemaCache[cacheKey] = new SchemaCacheEntry(lastWriteTimeUtc, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一个 schema 缓存键。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TableName">所属配置表名称。</param>
|
||||||
|
/// <param name="SchemaPath">Schema 文件绝对路径。</param>
|
||||||
|
private readonly record struct SchemaCacheKey(
|
||||||
|
string TableName,
|
||||||
|
string SchemaPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一个带文件时间戳的 schema 缓存条目。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="LastWriteTimeUtc">加载时观察到的 schema 文件修改时间。</param>
|
||||||
|
/// <param name="Schema">已解析的 schema 模型。</param>
|
||||||
|
private readonly record struct SchemaCacheEntry(
|
||||||
|
DateTime LastWriteTimeUtc,
|
||||||
|
YamlConfigSchema Schema);
|
||||||
|
}
|
||||||
@ -446,8 +446,22 @@ public class SchemaConfigGeneratorTests
|
|||||||
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
Does.Contain("public const string ConfigRelativePath = \"config/monster\";"));
|
Does.Contain("public const string ConfigRelativePath = \"config/monster\";"));
|
||||||
Assert.That(generatedSources["MonsterConfigBindings.g.cs"], Does.Contain("Metadata.ConfigRelativePath,"));
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"], Does.Contain("Metadata.ConfigRelativePath,"));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Contain("public static string SerializeToYaml(MonsterConfig config)"));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Contain("public static string GetSchemaPath(string configRootPath)"));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Contain("public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)"));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Contain("public static global::System.Threading.Tasks.Task ValidateYamlAsync("));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Contain("GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath)"));
|
||||||
|
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
|
||||||
|
Does.Not.Contain("private static string ResolveAbsolutePath"));
|
||||||
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
|
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
|
||||||
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
|
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
|
||||||
|
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
|
||||||
|
Does.Contain("internal static string ResolveAbsolutePath(string configRootPath, string relativePath)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -66,6 +66,31 @@ public static class GeneratedConfigCatalog
|
|||||||
MonsterConfigBindings.Metadata.SchemaRelativePath),
|
MonsterConfigBindings.Metadata.SchemaRelativePath),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves one generated relative config path against the caller-supplied config root directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
|
||||||
|
/// <param name="relativePath">Generated relative config or schema path.</param>
|
||||||
|
/// <returns>The combined absolute path.</returns>
|
||||||
|
/// <exception cref="global::System.ArgumentException">When <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
|
||||||
|
/// <exception cref="global::System.ArgumentNullException">When <paramref name="relativePath"/> is null.</exception>
|
||||||
|
internal static string ResolveAbsolutePath(string configRootPath, string relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configRootPath))
|
||||||
|
{
|
||||||
|
throw new global::System.ArgumentException("Config root path cannot be null or whitespace.", nameof(configRootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath is null)
|
||||||
|
{
|
||||||
|
throw new global::System.ArgumentNullException(nameof(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)
|
||||||
|
.Replace('\\', global::System.IO.Path.DirectorySeparatorChar);
|
||||||
|
return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tries to resolve generated table metadata by runtime registration name.
|
/// Tries to resolve generated table metadata by runtime registration name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -100,6 +100,82 @@ public static class MonsterConfigBindings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
|
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes one generated config instance to YAML text using the shared runtime naming convention.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The generated config instance to serialize.</param>
|
||||||
|
/// <returns>YAML text that preserves the shared camelCase field naming convention.</returns>
|
||||||
|
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="config"/> is <see langword="null"/>.</exception>
|
||||||
|
public static string SerializeToYaml(MonsterConfig config)
|
||||||
|
{
|
||||||
|
return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
|
||||||
|
/// <returns>The absolute config directory path for the generated table.</returns>
|
||||||
|
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
|
||||||
|
public static string GetConfigDirectoryPath(string configRootPath)
|
||||||
|
{
|
||||||
|
return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the absolute schema file path by combining the caller-supplied config root with the generated relative schema path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
|
||||||
|
/// <returns>The absolute schema file path for the generated table.</returns>
|
||||||
|
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
|
||||||
|
public static string GetSchemaPath(string configRootPath)
|
||||||
|
{
|
||||||
|
return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates YAML text against the generated schema file located under the supplied config root directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
|
||||||
|
/// <param name="yamlPath">Logical or absolute YAML path used for diagnostics.</param>
|
||||||
|
/// <param name="yamlText">YAML text to validate.</param>
|
||||||
|
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
|
||||||
|
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="yamlPath"/> or <paramref name="yamlText"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="global::GFramework.Game.Abstractions.Config.ConfigLoadException">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>
|
||||||
|
public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)
|
||||||
|
{
|
||||||
|
global::GFramework.Game.Config.YamlConfigTextValidator.Validate(
|
||||||
|
Metadata.TableName,
|
||||||
|
GetSchemaPath(configRootPath),
|
||||||
|
yamlPath,
|
||||||
|
yamlText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
|
||||||
|
/// <param name="yamlPath">Logical or absolute YAML path used for diagnostics.</param>
|
||||||
|
/// <param name="yamlText">YAML text to validate.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous validation operation.</returns>
|
||||||
|
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
|
||||||
|
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="yamlPath"/> or <paramref name="yamlText"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="global::GFramework.Game.Abstractions.Config.ConfigLoadException">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>
|
||||||
|
public static global::System.Threading.Tasks.Task ValidateYamlAsync(
|
||||||
|
string configRootPath,
|
||||||
|
string yamlPath,
|
||||||
|
string yamlText,
|
||||||
|
global::System.Threading.CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return global::GFramework.Game.Config.YamlConfigTextValidator.ValidateAsync(
|
||||||
|
Metadata.TableName,
|
||||||
|
GetSchemaPath(configRootPath),
|
||||||
|
yamlPath,
|
||||||
|
yamlText,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
|
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -17,14 +17,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
private const string ConfigPathMetadataKey = "x-gframework-config-path";
|
private const string ConfigPathMetadataKey = "x-gframework-config-path";
|
||||||
private const string LookupIndexMetadataKey = "x-gframework-index";
|
private const string LookupIndexMetadataKey = "x-gframework-index";
|
||||||
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
|
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
|
||||||
|
|
||||||
private const string LookupIndexTopLevelScalarOnlyMessage =
|
private const string LookupIndexTopLevelScalarOnlyMessage =
|
||||||
"Only top-level required non-key scalar properties can declare a generated lookup index.";
|
"Only top-level required non-key scalar properties can declare a generated lookup index.";
|
||||||
|
|
||||||
private const string LookupIndexRequiresRequiredScalarMessage =
|
private const string LookupIndexRequiresRequiredScalarMessage =
|
||||||
"Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null.";
|
"Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null.";
|
||||||
|
|
||||||
private const string LookupIndexPrimaryKeyMessage =
|
private const string LookupIndexPrimaryKeyMessage =
|
||||||
"The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index.";
|
"The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index.";
|
||||||
|
|
||||||
private const string LookupIndexReferencePropertyMessage =
|
private const string LookupIndexReferencePropertyMessage =
|
||||||
"Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics.";
|
"Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics.";
|
||||||
|
|
||||||
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', and 'uuid'";
|
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', and 'uuid'";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -434,7 +439,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
if (isIndexedLookup)
|
if (isIndexedLookup)
|
||||||
{
|
{
|
||||||
return ParsedPropertyResult.FromDiagnostic(
|
return ParsedPropertyResult.FromDiagnostic(
|
||||||
CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage));
|
CreateInvalidLookupIndexDiagnostic(filePath, displayPath,
|
||||||
|
LookupIndexTopLevelScalarOnlyMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(refTableName))
|
if (!string.IsNullOrWhiteSpace(refTableName))
|
||||||
@ -752,7 +758,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var itemType = itemTypeElement.GetString() ?? string.Empty;
|
var itemType = itemTypeElement.GetString() ?? string.Empty;
|
||||||
if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType, out var formatDiagnostic))
|
if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType,
|
||||||
|
out var formatDiagnostic))
|
||||||
{
|
{
|
||||||
return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!);
|
return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!);
|
||||||
}
|
}
|
||||||
@ -1128,6 +1135,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
builder.AppendLine(" /// </summary>");
|
builder.AppendLine(" /// </summary>");
|
||||||
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
|
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
|
AppendYamlSerializationHelpers(builder, schema);
|
||||||
|
builder.AppendLine();
|
||||||
builder.AppendLine(" /// <summary>");
|
builder.AppendLine(" /// <summary>");
|
||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
|
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
|
||||||
@ -1394,6 +1403,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
builder.AppendLine(" });");
|
builder.AppendLine(" });");
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
builder.AppendLine(" /// <summary>");
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Resolves one generated relative config path against the caller-supplied config root directory.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
|
||||||
|
builder.AppendLine(" /// <param name=\"relativePath\">Generated relative config or schema path.</param>");
|
||||||
|
builder.AppendLine(" /// <returns>The combined absolute path.</returns>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentException\">When <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"relativePath\"/> is null.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" internal static string ResolveAbsolutePath(string configRootPath, string relativePath)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(" if (string.IsNullOrWhiteSpace(configRootPath))");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(
|
||||||
|
" throw new global::System.ArgumentException(\"Config root path cannot be null or whitespace.\", nameof(configRootPath));");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" if (relativePath is null)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(relativePath));");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(
|
||||||
|
" var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)");
|
||||||
|
builder.AppendLine(
|
||||||
|
" .Replace('\\\\', global::System.IO.Path.DirectorySeparatorChar);");
|
||||||
|
builder.AppendLine(
|
||||||
|
" return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name.");
|
builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name.");
|
||||||
builder.AppendLine(" /// </summary>");
|
builder.AppendLine(" /// </summary>");
|
||||||
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
|
builder.AppendLine(" /// <param name=\"tableName\">Runtime registration name.</param>");
|
||||||
@ -1430,7 +1473,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
" /// Resolves the generated table metadata entries that belong to the specified logical config domain.");
|
" /// Resolves the generated table metadata entries that belong to the specified logical config domain.");
|
||||||
builder.AppendLine(" /// </summary>");
|
builder.AppendLine(" /// </summary>");
|
||||||
builder.AppendLine(" /// <param name=\"configDomain\">Logical config domain derived from the schema base name.</param>");
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configDomain\">Logical config domain derived from the schema base name.</param>");
|
||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
" /// <returns>A deterministic metadata snapshot for the requested config domain, or an empty list when no generated table belongs to that domain.</returns>");
|
" /// <returns>A deterministic metadata snapshot for the requested config domain, or an empty list when no generated table belongs to that domain.</returns>");
|
||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
@ -1665,6 +1709,117 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
return builder.ToString().TrimEnd();
|
return builder.ToString().TrimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为生成的绑定类输出 YAML 序列化与 schema 路径辅助。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">输出缓冲区。</param>
|
||||||
|
/// <param name="schema">生成器级 schema 模型。</param>
|
||||||
|
private static void AppendYamlSerializationHelpers(
|
||||||
|
StringBuilder builder,
|
||||||
|
SchemaFileSpec schema)
|
||||||
|
{
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Serializes one generated config instance to YAML text using the shared runtime naming convention.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(" /// <param name=\"config\">The generated config instance to serialize.</param>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <returns>YAML text that preserves the shared camelCase field naming convention.</returns>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"config\"/> is <see langword=\"null\"/>.</exception>");
|
||||||
|
builder.AppendLine($" public static string SerializeToYaml({schema.ClassName} config)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
|
||||||
|
builder.AppendLine(" /// <returns>The absolute config directory path for the generated table.</returns>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
|
||||||
|
builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(
|
||||||
|
" return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Resolves the absolute schema file path by combining the caller-supplied config root with the generated relative schema path.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
|
||||||
|
builder.AppendLine(" /// <returns>The absolute schema file path for the generated table.</returns>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
|
||||||
|
builder.AppendLine(" public static string GetSchemaPath(string configRootPath)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(
|
||||||
|
" return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Validates YAML text against the generated schema file located under the supplied config root directory.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"yamlPath\">Logical or absolute YAML path used for diagnostics.</param>");
|
||||||
|
builder.AppendLine(" /// <param name=\"yamlText\">YAML text to validate.</param>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"yamlPath\"/> or <paramref name=\"yamlText\"/> is <see langword=\"null\"/>.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::GFramework.Game.Abstractions.Config.ConfigLoadException\">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(" global::GFramework.Game.Config.YamlConfigTextValidator.Validate(");
|
||||||
|
builder.AppendLine(" Metadata.TableName,");
|
||||||
|
builder.AppendLine(" GetSchemaPath(configRootPath),");
|
||||||
|
builder.AppendLine(" yamlPath,");
|
||||||
|
builder.AppendLine(" yamlText);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" /// <summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory.");
|
||||||
|
builder.AppendLine(" /// </summary>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <param name=\"yamlPath\">Logical or absolute YAML path used for diagnostics.</param>");
|
||||||
|
builder.AppendLine(" /// <param name=\"yamlText\">YAML text to validate.</param>");
|
||||||
|
builder.AppendLine(" /// <param name=\"cancellationToken\">Cancellation token.</param>");
|
||||||
|
builder.AppendLine(" /// <returns>A task that represents the asynchronous validation operation.</returns>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"yamlPath\"/> or <paramref name=\"yamlText\"/> is <see langword=\"null\"/>.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <exception cref=\"global::GFramework.Game.Abstractions.Config.ConfigLoadException\">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" public static global::System.Threading.Tasks.Task ValidateYamlAsync(");
|
||||||
|
builder.AppendLine(" string configRootPath,");
|
||||||
|
builder.AppendLine(" string yamlPath,");
|
||||||
|
builder.AppendLine(" string yamlText,");
|
||||||
|
builder.AppendLine(" global::System.Threading.CancellationToken cancellationToken = default)");
|
||||||
|
builder.AppendLine(" {");
|
||||||
|
builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextValidator.ValidateAsync(");
|
||||||
|
builder.AppendLine(" Metadata.TableName,");
|
||||||
|
builder.AppendLine(" GetSchemaPath(configRootPath),");
|
||||||
|
builder.AppendLine(" yamlPath,");
|
||||||
|
builder.AppendLine(" yamlText,");
|
||||||
|
builder.AppendLine(" cancellationToken);");
|
||||||
|
builder.AppendLine(" }");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
|
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1778,8 +1933,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
" /// Materializes a read-only exact-match lookup index from the current table snapshot.");
|
" /// Materializes a read-only exact-match lookup index from the current table snapshot.");
|
||||||
builder.AppendLine(" /// </summary>");
|
builder.AppendLine(" /// </summary>");
|
||||||
builder.AppendLine(" /// <typeparam name=\"TProperty\">Indexed property type.</typeparam>");
|
builder.AppendLine(" /// <typeparam name=\"TProperty\">Indexed property type.</typeparam>");
|
||||||
builder.AppendLine(" /// <param name=\"keySelector\">Selects the indexed property from one config entry.</param>");
|
builder.AppendLine(
|
||||||
builder.AppendLine(" /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>");
|
" /// <param name=\"keySelector\">Selects the indexed property from one config entry.</param>");
|
||||||
|
builder.AppendLine(
|
||||||
|
" /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>");
|
||||||
builder.AppendLine(" /// <remarks>");
|
builder.AppendLine(" /// <remarks>");
|
||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
" /// The generated index skips runtime null keys even though <typeparamref name=\"TProperty\"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword=\"null\" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
|
" /// The generated index skips runtime null keys even though <typeparamref name=\"TProperty\"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword=\"null\" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
|
||||||
@ -1789,8 +1946,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
|
builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
|
||||||
builder.AppendLine(" where TProperty : notnull");
|
builder.AppendLine(" where TProperty : notnull");
|
||||||
builder.AppendLine(" {");
|
builder.AppendLine(" {");
|
||||||
builder.AppendLine(" var buckets = new global::System.Collections.Generic.Dictionary<TProperty, global::System.Collections.Generic.List<" +
|
builder.AppendLine(
|
||||||
$"{schema.ClassName}>>();");
|
" var buckets = new global::System.Collections.Generic.Dictionary<TProperty, global::System.Collections.Generic.List<" +
|
||||||
|
$"{schema.ClassName}>>();");
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
builder.AppendLine(
|
builder.AppendLine(
|
||||||
" // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance.");
|
" // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance.");
|
||||||
@ -1808,7 +1966,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
|
builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
|
||||||
builder.AppendLine(" {");
|
builder.AppendLine(" {");
|
||||||
builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
|
builder.AppendLine(
|
||||||
|
$" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
|
||||||
builder.AppendLine(" buckets.Add(key, matches);");
|
builder.AppendLine(" buckets.Add(key, matches);");
|
||||||
builder.AppendLine(" }");
|
builder.AppendLine(" }");
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
@ -2830,7 +2989,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
|||||||
|
|
||||||
return schemaType switch
|
return schemaType switch
|
||||||
{
|
{
|
||||||
"integer" when constElement.ValueKind == JsonValueKind.Number && constElement.TryGetInt64(out var intValue) =>
|
"integer" when constElement.ValueKind == JsonValueKind.Number &&
|
||||||
|
constElement.TryGetInt64(out var intValue) =>
|
||||||
intValue.ToString(CultureInfo.InvariantCulture),
|
intValue.ToString(CultureInfo.InvariantCulture),
|
||||||
"number" when constElement.ValueKind == JsonValueKind.Number =>
|
"number" when constElement.ValueKind == JsonValueKind.Number =>
|
||||||
constElement.GetDouble().ToString(CultureInfo.InvariantCulture),
|
constElement.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user