diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index a80a1990..b3c726eb 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -220,7 +220,8 @@ public class GeneratedConfigConsumerIntegrationTests
},
"name": {
"type": "string",
- "description": "Monster display name."
+ "description": "Monster display name.",
+ "x-gframework-index": true
},
"hp": {
"type": "integer",
@@ -228,7 +229,8 @@ public class GeneratedConfigConsumerIntegrationTests
},
"faction": {
"type": "string",
- "description": "Used by the integration test to validate generated non-unique queries."
+ "description": "Used by the integration test to validate generated non-unique queries.",
+ "x-gframework-index": true
}
}
}
diff --git a/GFramework.Game.Tests/schemas/monster.schema.json b/GFramework.Game.Tests/schemas/monster.schema.json
index 5bda7ba0..a43a481c 100644
--- a/GFramework.Game.Tests/schemas/monster.schema.json
+++ b/GFramework.Game.Tests/schemas/monster.schema.json
@@ -15,7 +15,8 @@
},
"name": {
"type": "string",
- "description": "Monster display name."
+ "description": "Monster display name.",
+ "x-gframework-index": true
},
"hp": {
"type": "integer",
@@ -23,7 +24,8 @@
},
"faction": {
"type": "string",
- "description": "Used by integration tests to validate generated non-unique queries."
+ "description": "Used by integration tests to validate generated non-unique queries.",
+ "x-gframework-index": true
}
}
}
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
index 8dd98ed5..290985ee 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs
@@ -79,6 +79,7 @@ public class SchemaConfigGeneratorSnapshotTests
"type": "string",
"title": "Monster Name",
"description": "Localized monster display name.",
+ "x-gframework-index": true,
"minLength": 3,
"maxLength": 16,
"pattern": "^[A-Z][a-z]+$",
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index 4f91f348..7cc62558 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -267,6 +267,100 @@ public class SchemaConfigGeneratorTests
});
}
+ ///
+ /// 验证查询索引元数据必须是布尔值,避免 schema 作者误以为字符串或数字也会被解释为开关。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Lookup_Index_Metadata_Is_Not_Boolean()
+ {
+ const string source = """
+ namespace TestApp
+ {
+ public sealed class Dummy
+ {
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": {
+ "type": "string",
+ "x-gframework-index": "yes"
+ }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var diagnostic = result.Results.Single().Diagnostics.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_008"));
+ Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("x-gframework-index"));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("boolean"));
+ });
+ }
+
+ ///
+ /// 验证查询索引元数据不能绑定到不满足约束的字段上,避免为嵌套字段生成误导性 API。
+ ///
+ [Test]
+ public void Run_Should_Report_Diagnostic_When_Lookup_Index_Metadata_Target_Is_Not_Eligible()
+ {
+ const string source = """
+ namespace TestApp
+ {
+ public sealed class Dummy
+ {
+ }
+ }
+ """;
+
+ const string schema = """
+ {
+ "type": "object",
+ "required": ["id", "reward"],
+ "properties": {
+ "id": { "type": "integer" },
+ "reward": {
+ "type": "object",
+ "required": ["rarity"],
+ "properties": {
+ "rarity": {
+ "type": "string",
+ "x-gframework-index": true
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ var result = SchemaGeneratorTestDriver.Run(
+ source,
+ ("monster.schema.json", schema));
+
+ var diagnostic = result.Results.Single().Diagnostics.Single();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_008"));
+ Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("reward.rarity"));
+ Assert.That(diagnostic.GetMessage(), Does.Contain("top-level required non-key scalar"));
+ });
+ }
+
///
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
///
@@ -429,7 +523,10 @@ public class SchemaConfigGeneratorTests
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
- "name": { "type": "string" },
+ "name": {
+ "type": "string",
+ "x-gframework-index": true
+ },
"hp": { "type": "integer" },
"dropItems": {
"type": "array",
@@ -468,8 +565,12 @@ public class SchemaConfigGeneratorTests
{
Assert.That(tableSource, Does.Contain("FindByName(string value)"));
Assert.That(tableSource, Does.Contain("TryFindFirstByName(string value, out MonsterConfig? result)"));
+ Assert.That(tableSource, Does.Contain("_nameIndex"));
+ Assert.That(tableSource, Does.Contain("BuildNameIndex"));
+ Assert.That(tableSource, Does.Contain("_nameIndex.Value.TryGetValue(value, out var matches)"));
Assert.That(tableSource, Does.Contain("FindByHp(int? value)"));
Assert.That(tableSource, Does.Contain("TryFindFirstByHp(int? value, out MonsterConfig? result)"));
+ Assert.That(tableSource, Does.Not.Contain("_hpIndex"));
Assert.That(tableSource, Does.Not.Contain("FindById("));
Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
index f9cd82a2..96b7e6aa 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt
@@ -10,6 +10,7 @@ namespace GFramework.Game.Config.Generated;
public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable
{
private readonly global::GFramework.Game.Abstractions.Config.IConfigTable _inner;
+ private readonly global::System.Lazy>> _nameIndex;
///
/// Creates a generated table wrapper around the runtime config table instance.
@@ -18,6 +19,9 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
public MonsterTable(global::GFramework.Game.Abstractions.Config.IConfigTable inner)
{
_inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));
+ _nameIndex = new global::System.Lazy>>(
+ BuildNameIndex,
+ global::System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
}
///
@@ -53,28 +57,65 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
return _inner.All();
}
+ ///
+ /// Builds the exact-match lookup index declared for property 'name'.
+ ///
+ /// A read-only lookup index keyed by Name.
+ private global::System.Collections.Generic.IReadOnlyDictionary> BuildNameIndex()
+ {
+ return BuildLookupIndex(static config => config.Name);
+ }
+
+ ///
+ /// Materializes a read-only exact-match lookup index from the current table snapshot.
+ ///
+ /// Indexed property type.
+ /// Selects the indexed property from one config entry.
+ /// A read-only dictionary whose values preserve snapshot iteration order.
+ private global::System.Collections.Generic.IReadOnlyDictionary> BuildLookupIndex(
+ global::System.Func keySelector)
+ where TProperty : notnull
+ {
+ var buckets = new global::System.Collections.Generic.Dictionary>();
+
+ // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance.
+ foreach (var candidate in All())
+ {
+ var key = keySelector(candidate);
+ if (!buckets.TryGetValue(key, out var matches))
+ {
+ matches = new global::System.Collections.Generic.List();
+ buckets.Add(key, matches);
+ }
+
+ matches.Add(candidate);
+ }
+
+ var materialized = new global::System.Collections.Generic.Dictionary>(buckets.Count, buckets.Comparer);
+ foreach (var pair in buckets)
+ {
+ materialized.Add(pair.Key, global::System.Array.AsReadOnly(pair.Value.ToArray()));
+ }
+
+ return new global::System.Collections.ObjectModel.ReadOnlyDictionary>(materialized);
+ }
+
///
/// Finds all config entries whose property 'name' equals the supplied value.
///
/// The property value to match.
/// A read-only snapshot containing every matching config entry.
///
- /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
+ /// This property declares x-gframework-index, so the generated helper resolves matches through a lazily materialized read-only lookup index built from the current table snapshot.
///
public global::System.Collections.Generic.IReadOnlyList FindByName(string value)
{
- var matches = new global::System.Collections.Generic.List();
-
- // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
- foreach (var candidate in All())
+ if (_nameIndex.Value.TryGetValue(value, out var matches))
{
- if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value))
- {
- matches.Add(candidate);
- }
+ return matches;
}
- return matches.Count == 0 ? global::System.Array.Empty() : matches.AsReadOnly();
+ return global::System.Array.Empty();
}
///
@@ -84,18 +125,14 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
/// The first matching config entry when lookup succeeds; otherwise .
/// when a matching config entry is found; otherwise .
///
- /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.
+ /// This property declares x-gframework-index, so the generated helper returns the first element from the lazily materialized exact-match bucket.
///
public bool TryFindFirstByName(string value, out MonsterConfig? result)
{
- // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.
- foreach (var candidate in All())
+ if (_nameIndex.Value.TryGetValue(value, out var matches) && matches.Count > 0)
{
- if (global::System.Collections.Generic.EqualityComparer.Default.Equals(candidate.Name, value))
- {
- result = candidate;
- return true;
- }
+ result = matches[0];
+ return true;
}
result = null;
diff --git a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
index c7be999c..f8af86be 100644
--- a/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -25,6 +25,7 @@
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_007 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
+ GF_ConfigSchema_008 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index 7cc8690f..26e5f18a 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -11,6 +11,7 @@ namespace GFramework.SourceGenerators.Config;
public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
private const string ConfigPathMetadataKey = "x-gframework-config-path";
+ private const string LookupIndexMetadataKey = "x-gframework-index";
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
///
@@ -279,11 +280,32 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var title = TryGetMetadataString(property.Value, "title");
var description = TryGetMetadataString(property.Value, "description");
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
+ var indexedLookupMetadata = TryGetMetadataBoolean(property.Value, LookupIndexMetadataKey);
+ if (indexedLookupMetadata.Diagnostic is not null)
+ {
+ return ParsedPropertyResult.FromDiagnostic(
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ indexedLookupMetadata.Diagnostic!));
+ }
+
+ var isIndexedLookup = indexedLookupMetadata.Value ?? false;
if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic))
{
return ParsedPropertyResult.FromDiagnostic(diagnostic!);
}
+ if (isIndexedLookup &&
+ !TryValidateIndexedLookupEligibility(filePath, property.Name, displayPath, isRequired, refTableName,
+ out diagnostic))
+ {
+ return ParsedPropertyResult.FromDiagnostic(diagnostic!);
+ }
+
switch (schemaType)
{
case "integer":
@@ -294,6 +316,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"integer",
@@ -313,6 +336,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"number",
@@ -332,6 +356,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"boolean",
@@ -351,6 +376,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"string",
@@ -364,6 +390,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
null)));
case "object":
+ if (isIndexedLookup)
+ {
+ return ParsedPropertyResult.FromDiagnostic(
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "Only top-level required non-key scalar properties can declare a generated lookup index."));
+ }
+
if (!string.IsNullOrWhiteSpace(refTableName))
{
return ParsedPropertyResult.FromDiagnostic(
@@ -393,6 +431,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ false,
new SchemaTypeSpec(
SchemaNodeKind.Object,
"object",
@@ -406,7 +445,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
case "array":
return ParseArrayProperty(filePath, property, isRequired, displayPath, propertyName, title,
- description, refTableName);
+ description, refTableName, isIndexedLookup);
default:
return ParsedPropertyResult.FromDiagnostic(
@@ -419,6 +458,76 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
+ ///
+ /// 验证字段是否满足生成只读精确匹配索引的前提。
+ ///
+ /// Schema 文件路径。
+ /// Schema 原始字段名。
+ /// 逻辑字段路径。
+ /// 字段是否必填。
+ /// 可选的引用表名。
+ /// 不满足条件时输出的诊断。
+ /// 当前字段是否允许声明只读索引。
+ private static bool TryValidateIndexedLookupEligibility(
+ string filePath,
+ string schemaName,
+ string displayPath,
+ bool isRequired,
+ string? refTableName,
+ out Diagnostic? diagnostic)
+ {
+ diagnostic = null;
+ if (!IsTopLevelPropertyDisplayPath(displayPath))
+ {
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "Only top-level required non-key scalar properties can declare a generated lookup index.");
+ return false;
+ }
+
+ if (!isRequired)
+ {
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null.");
+ return false;
+ }
+
+ if (string.Equals(schemaName, "id", StringComparison.OrdinalIgnoreCase))
+ {
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index.");
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(refTableName))
+ {
+ diagnostic = Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics.");
+ return false;
+ }
+
+ return true;
+ }
+
///
/// 解析数组属性,支持标量数组与对象数组。
///
@@ -439,8 +548,21 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
string propertyName,
string? title,
string? description,
- string? refTableName)
+ string? refTableName,
+ bool isIndexedLookup)
{
+ if (isIndexedLookup)
+ {
+ return ParsedPropertyResult.FromDiagnostic(
+ Diagnostic.Create(
+ ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
+ CreateFileLocation(filePath),
+ Path.GetFileName(filePath),
+ displayPath,
+ LookupIndexMetadataKey,
+ "Only top-level required non-key scalar properties can declare a generated lookup index."));
+ }
+
if (!property.Value.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object ||
!itemsElement.TryGetProperty("type", out var itemTypeElement) ||
@@ -477,6 +599,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ false,
new SchemaTypeSpec(
SchemaNodeKind.Array,
"array",
@@ -528,6 +651,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
+ false,
new SchemaTypeSpec(
SchemaNodeKind.Array,
"array",
@@ -587,6 +711,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
{
var builder = new StringBuilder();
var queryableProperties = CollectQueryableProperties(schema).ToArray();
+ var indexedQueryableProperties = queryableProperties
+ .Where(static property => property.IsIndexedLookup)
+ .ToArray();
builder.AppendLine("// ");
builder.AppendLine("#nullable enable");
builder.AppendLine();
@@ -603,6 +730,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine("{");
builder.AppendLine(
$" private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> _inner;");
+ foreach (var property in indexedQueryableProperties)
+ {
+ builder.AppendLine(
+ $" private readonly global::System.Lazy>> _{ToCamelCase(property.PropertyName)}Index;");
+ }
+
builder.AppendLine();
builder.AppendLine(" /// ");
builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance.");
@@ -612,6 +745,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
$" public {schema.TableName}(global::GFramework.Game.Abstractions.Config.IConfigTable<{schema.KeyClrType}, {schema.ClassName}> inner)");
builder.AppendLine(" {");
builder.AppendLine(" _inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));");
+ foreach (var property in indexedQueryableProperties)
+ {
+ builder.AppendLine(
+ $" _{ToCamelCase(property.PropertyName)}Index = new global::System.Lazy>>(");
+ builder.AppendLine($" Build{property.PropertyName}Index,");
+ builder.AppendLine(" global::System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);");
+ }
+
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
@@ -648,6 +789,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" return _inner.All();");
builder.AppendLine(" }");
+ if (indexedQueryableProperties.Length > 0)
+ {
+ foreach (var property in indexedQueryableProperties)
+ {
+ builder.AppendLine();
+ AppendIndexedLookupBuilderMethod(builder, schema, property);
+ }
+
+ builder.AppendLine();
+ AppendSharedLookupIndexBuilderMethod(builder, schema);
+ }
+
foreach (var property in queryableProperties)
{
builder.AppendLine();
@@ -1348,6 +1501,82 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
+ ///
+ /// 为单个索引字段生成延迟构建器。
+ ///
+ /// 输出缓冲区。
+ /// 生成器级 schema 模型。
+ /// 声明了索引元数据的字段。
+ private static void AppendIndexedLookupBuilderMethod(
+ StringBuilder builder,
+ SchemaFileSpec schema,
+ SchemaPropertySpec property)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// Builds the exact-match lookup index declared for property '{EscapeXmlDocumentation(property.DisplayPath)}'.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ $" /// A read-only lookup index keyed by {EscapeXmlDocumentation(property.PropertyName)}.");
+ builder.AppendLine(
+ $" private global::System.Collections.Generic.IReadOnlyDictionary<{property.TypeSpec.ClrType}, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>> Build{property.PropertyName}Index()");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" return BuildLookupIndex(static config => config.{property.PropertyName});");
+ builder.AppendLine(" }");
+ }
+
+ ///
+ /// 为当前生成表输出共享索引构建逻辑。
+ ///
+ /// 输出缓冲区。
+ /// 生成器级 schema 模型。
+ private static void AppendSharedLookupIndexBuilderMethod(
+ StringBuilder builder,
+ SchemaFileSpec schema)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Materializes a read-only exact-match lookup index from the current table snapshot.");
+ builder.AppendLine(" /// ");
+ 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(
+ $" private global::System.Collections.Generic.IReadOnlyDictionary> BuildLookupIndex(");
+ builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
+ builder.AppendLine(" where TProperty : notnull");
+ builder.AppendLine(" {");
+ builder.AppendLine(" var buckets = new global::System.Collections.Generic.Dictionary>();");
+ builder.AppendLine();
+ builder.AppendLine(
+ " // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance.");
+ builder.AppendLine(" foreach (var candidate in All())");
+ builder.AppendLine(" {");
+ builder.AppendLine(" var key = keySelector(candidate);");
+ builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
+ builder.AppendLine(" {");
+ builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
+ builder.AppendLine(" buckets.Add(key, matches);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" matches.Add(candidate);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ $" var materialized = new global::System.Collections.Generic.Dictionary>(buckets.Count, buckets.Comparer);");
+ builder.AppendLine(" foreach (var pair in buckets)");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" materialized.Add(pair.Key, global::System.Array.AsReadOnly(pair.Value.ToArray()));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ $" return new global::System.Collections.ObjectModel.ReadOnlyDictionary>(materialized);");
+ builder.AppendLine(" }");
+ }
+
///
/// 生成按字段匹配全部结果的轻量查询辅助。
///
@@ -1366,28 +1595,51 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// The property value to match.");
builder.AppendLine(" /// A read-only snapshot containing every matching config entry.");
builder.AppendLine(" /// ");
- builder.AppendLine(
- " /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
+ if (property.IsIndexedLookup)
+ {
+ builder.AppendLine(
+ " /// This property declares x-gframework-index, so the generated helper resolves matches through a lazily materialized read-only lookup index built from the current table snapshot.");
+ }
+ else
+ {
+ builder.AppendLine(
+ " /// The generated helper performs a deterministic linear scan over so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
+ }
+
builder.AppendLine(" /// ");
builder.AppendLine(
$" public global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}> FindBy{property.PropertyName}({property.TypeSpec.ClrType} value)");
builder.AppendLine(" {");
- builder.AppendLine(
- $" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
- builder.AppendLine();
- builder.AppendLine(
- " // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.");
- builder.AppendLine(" foreach (var candidate in All())");
- builder.AppendLine(" {");
- builder.AppendLine(
- $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
- builder.AppendLine(" {");
- builder.AppendLine(" matches.Add(candidate);");
- builder.AppendLine(" }");
- builder.AppendLine(" }");
- builder.AppendLine();
- builder.AppendLine(
- $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();");
+ if (property.IsIndexedLookup)
+ {
+ builder.AppendLine(
+ $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches))");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return matches;");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine($" return global::System.Array.Empty<{schema.ClassName}>();");
+ }
+ else
+ {
+ builder.AppendLine(
+ $" var matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
+ builder.AppendLine();
+ builder.AppendLine(
+ " // Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.");
+ builder.AppendLine(" foreach (var candidate in All())");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
+ builder.AppendLine(" {");
+ builder.AppendLine(" matches.Add(candidate);");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ $" return matches.Count == 0 ? global::System.Array.Empty<{schema.ClassName}>() : matches.AsReadOnly();");
+ }
+
builder.AppendLine(" }");
}
@@ -1412,26 +1664,51 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// when a matching config entry is found; otherwise .");
builder.AppendLine(" /// ");
- builder.AppendLine(
- " /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.");
+ if (property.IsIndexedLookup)
+ {
+ builder.AppendLine(
+ " /// This property declares x-gframework-index, so the generated helper returns the first element from the lazily materialized exact-match bucket.");
+ }
+ else
+ {
+ builder.AppendLine(
+ " /// The generated helper walks the same snapshot exposed by and returns the first match in iteration order.");
+ }
+
builder.AppendLine(" /// ");
builder.AppendLine(
$" public bool TryFindFirstBy{property.PropertyName}({property.TypeSpec.ClrType} value, out {schema.ClassName}? result)");
builder.AppendLine(" {");
- builder.AppendLine(
- " // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.");
- builder.AppendLine(" foreach (var candidate in All())");
- builder.AppendLine(" {");
- builder.AppendLine(
- $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
- builder.AppendLine(" {");
- builder.AppendLine(" result = candidate;");
- builder.AppendLine(" return true;");
- builder.AppendLine(" }");
- builder.AppendLine(" }");
- builder.AppendLine();
- builder.AppendLine(" result = null;");
- builder.AppendLine(" return false;");
+ if (property.IsIndexedLookup)
+ {
+ builder.AppendLine(
+ $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches) && matches.Count > 0)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" result = matches[0];");
+ builder.AppendLine(" return true;");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" result = null;");
+ builder.AppendLine(" return false;");
+ }
+ else
+ {
+ builder.AppendLine(
+ " // Keep the search path allocation-free for the first-match case by exiting as soon as one entry matches.");
+ builder.AppendLine(" foreach (var candidate in All())");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ $" if (global::System.Collections.Generic.EqualityComparer<{property.TypeSpec.ClrType}>.Default.Equals(candidate.{property.PropertyName}, value))");
+ builder.AppendLine(" {");
+ builder.AppendLine(" result = candidate;");
+ builder.AppendLine(" return true;");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" result = null;");
+ builder.AppendLine(" return false;");
+ }
+
builder.AppendLine(" }");
}
@@ -1715,6 +1992,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return $"schemas/{Path.GetFileName(path)}";
}
+ ///
+ /// 判断字段路径是否表示根对象的直接子字段。
+ ///
+ /// 逻辑字段路径。
+ /// 根对象直接子字段时返回 true;否则返回 false。
+ private static bool IsTopLevelPropertyDisplayPath(string displayPath)
+ {
+ return displayPath.IndexOf('.') < 0 &&
+ displayPath.IndexOf('[') < 0;
+ }
+
///
/// 将 schema 名称转换为 PascalCase 标识符。
///
@@ -1731,6 +2019,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
}
+ ///
+ /// 将 PascalCase 标识符转换为 camelCase 字段名。
+ ///
+ /// PascalCase 标识符。
+ /// 适合作为私有字段名的 camelCase 标识符。
+ private static string ToCamelCase(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ if (value.Length == 1)
+ {
+ return char.ToLowerInvariant(value[0]).ToString();
+ }
+
+ return char.ToLowerInvariant(value[0]) + value.Substring(1);
+ }
+
///
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
///
@@ -1784,6 +2092,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return string.IsNullOrWhiteSpace(value) ? null : value;
}
+ ///
+ /// 读取布尔元数据。
+ ///
+ /// Schema 节点。
+ /// 元数据字段名。
+ /// 布尔元数据值;不存在时返回空。
+ private static (bool? Value, string? Diagnostic) TryGetMetadataBoolean(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var metadataElement))
+ {
+ return (null, null);
+ }
+
+ if (metadataElement.ValueKind != JsonValueKind.True &&
+ metadataElement.ValueKind != JsonValueKind.False)
+ {
+ return (null, $"Expected a JSON boolean but found '{metadataElement.ValueKind}'.");
+ }
+
+ return (metadataElement.GetBoolean(), null);
+ }
+
///
/// 解析 schema 顶层配置目录元数据。
///
@@ -2210,6 +2540,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// 是否必填。
/// 字段标题元数据。
/// 字段描述元数据。
+ /// 是否声明生成只读精确匹配索引。
/// 字段类型模型。
private sealed record SchemaPropertySpec(
string SchemaName,
@@ -2218,6 +2549,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
bool IsRequired,
string? Title,
string? Description,
+ bool IsIndexedLookup,
SchemaTypeSpec TypeSpec);
///
diff --git a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
index 83193c4b..b3d6be65 100644
--- a/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
+++ b/GFramework.SourceGenerators/Diagnostics/ConfigSchemaDiagnostics.cs
@@ -85,4 +85,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
+
+ ///
+ /// schema 字段的查询索引元数据无效。
+ ///
+ public static readonly DiagnosticDescriptor InvalidLookupIndexMetadata = new(
+ "GF_ConfigSchema_008",
+ "Config schema uses invalid lookup index metadata",
+ "Property '{1}' in schema file '{0}' uses invalid '{2}' metadata: {3}",
+ SourceGeneratorsConfigCategory,
+ DiagnosticSeverity.Error,
+ true);
}
diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md
index 2e38c5bf..09395cec 100644
--- a/docs/zh-CN/game/config-system.md
+++ b/docs/zh-CN/game/config-system.md
@@ -365,6 +365,18 @@ var slime = runtime.GetMonster(1);
从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。
+如果某个字段属于高频精确匹配条件,可以在 schema 中显式声明:
+
+```json
+{
+ "type": "string",
+ "x-gframework-index": true
+}
+```
+
+当前这个元数据只支持“顶层、必填、非主键、非引用标量字段”。命中该条件时,生成的 `FindBy*` /
+`TryFindFirstBy*` API 不会变,但内部会改成按需构建只读精确匹配索引;没有声明的字段仍保持线性扫描。
+
如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用:
```csharp
@@ -383,7 +395,8 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
- 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*`
- 主键字段继续只走 `Get / TryGet`
- 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助
-- 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存
+- 只有显式声明 `x-gframework-index: true` 的字段才会生成惰性只读索引
+- 未声明索引的字段继续基于 `All()` 做线性扫描,不引入额外运行时索引成本
这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。