diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index 75ea6faf..24e53bd2 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -226,6 +226,94 @@ public class SchemaConfigGeneratorTests
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
}
+ ///
+ /// 验证生成的索引构建逻辑会跳过运行时空 key,避免 Lazy 索引因格式错误数据永久失效。
+ ///
+ [Test]
+ public void Run_Should_Skip_Runtime_Null_Keys_When_Generating_Indexed_Lookups()
+ {
+ const string source = """
+ using System;
+ using System.Collections.Generic;
+
+ namespace GFramework.Game.Abstractions.Config
+ {
+ public interface IConfigTable
+ {
+ Type KeyType { get; }
+ Type ValueType { get; }
+ int Count { get; }
+ }
+
+ public interface IConfigTable : IConfigTable
+ where TKey : notnull
+ {
+ TValue Get(TKey key);
+ bool TryGet(TKey key, out TValue? value);
+ bool ContainsKey(TKey key);
+ IReadOnlyCollection All();
+ }
+
+ public interface IConfigRegistry
+ {
+ IConfigTable GetTable(string name)
+ where TKey : notnull;
+
+ bool TryGetTable(string name, out IConfigTable? table)
+ where TKey : notnull;
+ }
+ }
+
+ namespace GFramework.Game.Config
+ {
+ public sealed class YamlConfigLoader
+ {
+ public YamlConfigLoader RegisterTable(
+ string tableName,
+ string relativePath,
+ string schemaRelativePath,
+ Func keySelector,
+ IEqualityComparer? comparer = null)
+ where TKey : notnull
+ {
+ return this;
+ }
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": {
+ "type": "string",
+ "x-gframework-index": true
+ }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var generatedSources = result.Results
+ .Single()
+ .GeneratedSources
+ .ToDictionary(
+ static sourceResult => sourceResult.HintName,
+ static sourceResult => sourceResult.SourceText.ToString(),
+ StringComparer.Ordinal);
+
+ Assert.That(result.Results.Single().Diagnostics, Is.Empty);
+ Assert.That(generatedSources["MonsterTable.g.cs"], Does.Contain("if (key is null)"));
+ Assert.That(generatedSources["MonsterTable.g.cs"],
+ Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
+ }
+
///
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
///
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
index 88002a63..b187bd3f 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
@@ -72,6 +72,9 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
/// Indexed property type.
/// Selects the indexed property from one config entry.
/// A read-only dictionary whose values preserve snapshot iteration order.
+ ///
+ /// The generated index skips runtime null keys even though is constrained to notnull. Malformed YAML payloads can still deserialize missing indexed values to , and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.
+ ///
private global::System.Collections.Generic.IReadOnlyDictionary> BuildLookupIndex(
global::System.Func keySelector)
where TProperty : notnull
@@ -82,6 +85,13 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
foreach (var candidate in All())
{
var key = keySelector(candidate);
+ if (key is null)
+ {
+ // Skip malformed runtime data so the lazy lookup cache remains usable for valid keys.
+ // Throwing here would permanently poison the cached index for this wrapper instance.
+ continue;
+ }
+
if (!buckets.TryGetValue(key, out var matches))
{
matches = new global::System.Collections.Generic.List();
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 09a7845c..e60c1892 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -1536,6 +1536,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// Indexed property type.");
builder.AppendLine(" /// Selects the indexed property from one config entry.");
builder.AppendLine(" /// A read-only dictionary whose values preserve snapshot iteration order.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// The generated index skips runtime null keys even though is constrained to notnull. Malformed YAML payloads can still deserialize missing indexed values to , and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
+ builder.AppendLine(" /// ");
builder.AppendLine(
$" private global::System.Collections.Generic.IReadOnlyDictionary> BuildLookupIndex(");
builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
@@ -1549,6 +1553,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" foreach (var candidate in All())");
builder.AppendLine(" {");
builder.AppendLine(" var key = keySelector(candidate);");
+ builder.AppendLine(" if (key is null)");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " // Skip malformed runtime data so the lazy lookup cache remains usable for valid keys.");
+ builder.AppendLine(
+ " // Throwing here would permanently poison the cached index for this wrapper instance.");
+ builder.AppendLine(" continue;");
+ builder.AppendLine(" }");
+ builder.AppendLine();
builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
builder.AppendLine(" {");
builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
@@ -1606,7 +1619,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {");
if (property.IsIndexedLookup)
{
- if (IsReferenceType(property.TypeSpec.ClrType))
+ if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{
builder.AppendLine(" if (value is null)");
builder.AppendLine(" {");
@@ -1684,7 +1697,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {");
if (property.IsIndexedLookup)
{
- if (IsReferenceType(property.TypeSpec.ClrType))
+ if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{
builder.AppendLine(" if (value is null)");
builder.AppendLine(" {");
@@ -2138,13 +2151,23 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
///
- /// 判断生成字段类型是否为引用类型。
+ /// 判断某个已支持的索引标量映射是否需要在查询辅助中生成空值守卫。
+ /// 这里必须显式枚举所有已支持的 schema 标量类型,避免未来新增引用类型标量时静默漏掉空检查。
///
- /// 生成的 CLR 类型名。
- /// 引用类型时返回 true;否则返回 false。
- private static bool IsReferenceType(string clrType)
+ /// 生成字段的标量类型模型。
+ /// 需要在生成的索引查询辅助中保护 参数时返回 true;否则返回 false。
+ /// 当前受支持的标量映射未被完整分类时抛出。
+ private static bool RequiresIndexedLookupNullGuard(SchemaTypeSpec typeSpec)
{
- return string.Equals(clrType, "string", StringComparison.Ordinal);
+ return typeSpec.SchemaType switch
+ {
+ "integer" => false,
+ "number" => false,
+ "boolean" => false,
+ "string" => true,
+ _ => throw new InvalidOperationException(
+ $"Indexed lookup null-guard classification does not cover schema scalar type '{typeSpec.SchemaType}' mapped to '{typeSpec.ClrType}'.")
+ };
}
///