From 43b95c7513a07ca800e6fae78aee56d687d0e2ce Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:32:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增配置系统架构说明,涵盖 YAML 源文件、JSON Schema 结构描述、运行时只读查询等核心功能 - 完善推荐目录结构和 Schema 示例,包括怪物、物品配置表的标准定义方式 - 提供完整的接入模板,包含 csproj 配置、GameConfigHost 生命周期管理、GameConfigRuntime 读取入口 - 添加运行时校验行为说明,支持必填字段、类型匹配、数值范围、字符串长度、正则表达式、数组长度等多种约束 - 集成跨表引用功能,支持通过 x-gframework-ref-table 声明关联关系并进行有效性检查 - 添加开发期热重载支持,可自动监听配置目录和 schema 文件变更并重载对应表格 - 提供 VS Code 工具集成说明,包括配置浏览、raw 编辑、schema 打开、表单入口等功能 - 补充生成器接入约定,从 *.schema.json 自动生成配置类型、表包装、注册辅助等代码 - 添加完整的 Analyzer 规则文档,涵盖 GF_ConfigSchema_001 到 GF_ConfigSchema_008 等错误诊断码 - 增加单元测试验证,确保消费者项目可以正常使用生成的聚合注册辅助和强类型访问入口 --- ...GeneratedConfigConsumerIntegrationTests.cs | 6 +- .../schemas/monster.schema.json | 6 +- .../SchemaConfigGeneratorSnapshotTests.cs | 1 + .../Config/SchemaConfigGeneratorTests.cs | 103 ++++- .../SchemaConfigGenerator/MonsterTable.g.txt | 73 +++- .../AnalyzerReleases.Unshipped.md | 1 + .../Config/SchemaConfigGenerator.cs | 404 ++++++++++++++++-- .../Diagnostics/ConfigSchemaDiagnostics.cs | 11 + docs/zh-CN/game/config-system.md | 15 +- 9 files changed, 560 insertions(+), 60 deletions(-) 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()` 做线性扫描,不引入额外运行时索引成本 这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。 From 9b139856159eb229c0d9797e6087d34f10453992 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:14:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(generator):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9E=B6=E6=9E=84=E4=BB=A3=E7=A0=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 SchemaConfigGenerator 源代码生成器 - 支持从 JSON schema 文件生成配置类型和配置表包装类 - 生成强类型的 MonsterTable 配置表包装器 - 实现基于属性名称的查找索引构建功能 - 提供自动生成的 FindByName 和 TryFindFirstByName 方法 - 支持配置表的精确匹配查询操作 - 生成配置绑定辅助类和元数据常量 - 实现跨表引用关系的元数据提取功能 --- .../Config/SchemaConfigGeneratorTests.cs | 90 +++++++++++ .../SchemaConfigGenerator/MonsterTable.g.txt | 13 +- .../Config/SchemaConfigGenerator.cs | 140 +++++++++++------- 3 files changed, 188 insertions(+), 55 deletions(-) diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 7cc62558..75ea6faf 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -361,6 +361,93 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证根对象直接字段即使 schema key 本身包含点号,也不会被错误识别为嵌套字段。 + /// + [Test] + public void Run_Should_Allow_Lookup_Index_For_Direct_Root_Property_With_Dotted_Schema_Key() + { + 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", "display.name"], + "properties": { + "id": { "type": "integer" }, + "display.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("FindByDisplayName(string value)")); + Assert.That(generatedSources["MonsterTable.g.cs"], Does.Contain("_displayNameIndex")); + } + /// /// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。 /// @@ -567,7 +654,10 @@ public class SchemaConfigGeneratorTests 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("if (value is null)")); Assert.That(tableSource, Does.Contain("_nameIndex.Value.TryGetValue(value, out var matches)")); + Assert.That(tableSource, Does.Contain("materialized.Add(pair.Key, pair.Value.AsReadOnly());")); + Assert.That(tableSource, Does.Not.Contain("pair.Value.ToArray()")); 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")); diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt index 96b7e6aa..88002a63 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterTable.g.txt @@ -94,7 +94,7 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions. 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())); + materialized.Add(pair.Key, pair.Value.AsReadOnly()); } return new global::System.Collections.ObjectModel.ReadOnlyDictionary>(materialized); @@ -110,6 +110,11 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions. /// public global::System.Collections.Generic.IReadOnlyList FindByName(string value) { + if (value is null) + { + return global::System.Array.Empty(); + } + if (_nameIndex.Value.TryGetValue(value, out var matches)) { return matches; @@ -129,6 +134,12 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions. /// public bool TryFindFirstByName(string value, out MonsterConfig? result) { + if (value is null) + { + result = null; + return false; + } + if (_nameIndex.Value.TryGetValue(value, out var matches) && matches.Count > 0) { result = matches[0]; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 26e5f18a..38fe13bf 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -13,6 +13,14 @@ 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"; + private const string LookupIndexTopLevelScalarOnlyMessage = + "Only top-level required non-key scalar properties can declare a generated lookup index."; + private const string LookupIndexRequiresRequiredScalarMessage = + "Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null."; + private const string LookupIndexPrimaryKeyMessage = + "The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index."; + private const string LookupIndexReferencePropertyMessage = + "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics."; /// public void Initialize(IncrementalGeneratorInitializationContext context) @@ -233,7 +241,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator filePath, property, requiredProperties.Contains(property.Name), - CombinePath(displayPath, property.Name)); + CombinePath(displayPath, property.Name), + isDirectChildOfRoot: isRoot); if (parsedProperty.Diagnostic is not null) { return ParsedObjectResult.FromDiagnostic(parsedProperty.Diagnostic); @@ -262,7 +271,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string filePath, JsonProperty property, bool isRequired, - string displayPath) + string displayPath, + bool isDirectChildOfRoot) { if (!property.Value.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) @@ -300,7 +310,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } if (isIndexedLookup && - !TryValidateIndexedLookupEligibility(filePath, property.Name, displayPath, isRequired, refTableName, + !TryValidateIndexedLookupEligibility( + filePath, + property.Name, + displayPath, + isDirectChildOfRoot, + isRequired, + refTableName, out diagnostic)) { return ParsedPropertyResult.FromDiagnostic(diagnostic!); @@ -393,13 +409,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator 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.")); + CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage)); } if (!string.IsNullOrWhiteSpace(refTableName)) @@ -472,56 +482,45 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string filePath, string schemaName, string displayPath, + bool isDirectChildOfRoot, bool isRequired, string? refTableName, out Diagnostic? diagnostic) { diagnostic = null; - if (!IsTopLevelPropertyDisplayPath(displayPath)) + if (!isDirectChildOfRoot) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + diagnostic = CreateInvalidLookupIndexDiagnostic( + filePath, displayPath, - LookupIndexMetadataKey, - "Only top-level required non-key scalar properties can declare a generated lookup index."); + LookupIndexTopLevelScalarOnlyMessage); return false; } if (!isRequired) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + diagnostic = CreateInvalidLookupIndexDiagnostic( + filePath, displayPath, - LookupIndexMetadataKey, - "Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null."); + LookupIndexRequiresRequiredScalarMessage); return false; } if (string.Equals(schemaName, "id", StringComparison.OrdinalIgnoreCase)) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + diagnostic = CreateInvalidLookupIndexDiagnostic( + filePath, displayPath, - LookupIndexMetadataKey, - "The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index."); + LookupIndexPrimaryKeyMessage); return false; } if (!string.IsNullOrWhiteSpace(refTableName)) { - diagnostic = Diagnostic.Create( - ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, - CreateFileLocation(filePath), - Path.GetFileName(filePath), + diagnostic = CreateInvalidLookupIndexDiagnostic( + filePath, displayPath, - LookupIndexMetadataKey, - "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics."); + LookupIndexReferencePropertyMessage); return false; } @@ -554,13 +553,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator 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.")); + CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage)); } if (!property.Value.TryGetProperty("items", out var itemsElement) || @@ -1569,7 +1562,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" foreach (var pair in buckets)"); builder.AppendLine(" {"); builder.AppendLine( - $" materialized.Add(pair.Key, global::System.Array.AsReadOnly(pair.Value.ToArray()));"); + $" materialized.Add(pair.Key, pair.Value.AsReadOnly());"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine( @@ -1612,6 +1605,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" {"); if (property.IsIndexedLookup) { + if (IsReferenceType(property.TypeSpec.ClrType)) + { + builder.AppendLine(" if (value is null)"); + builder.AppendLine(" {"); + builder.AppendLine($" return global::System.Array.Empty<{schema.ClassName}>();"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + builder.AppendLine( $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches))"); builder.AppendLine(" {"); @@ -1681,6 +1683,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator builder.AppendLine(" {"); if (property.IsIndexedLookup) { + if (IsReferenceType(property.TypeSpec.ClrType)) + { + builder.AppendLine(" if (value is null)"); + builder.AppendLine(" {"); + builder.AppendLine(" result = null;"); + builder.AppendLine(" return false;"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + builder.AppendLine( $" if (_{ToCamelCase(property.PropertyName)}Index.Value.TryGetValue(value, out var matches) && matches.Count > 0)"); builder.AppendLine(" {"); @@ -1992,17 +2004,6 @@ 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 标识符。 /// @@ -2092,6 +2093,27 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return string.IsNullOrWhiteSpace(value) ? null : value; } + /// + /// 创建统一格式的无效查询索引元数据诊断。 + /// + /// Schema 文件路径。 + /// 逻辑字段路径。 + /// 具体失败原因。 + /// 稳定的查询索引诊断。 + private static Diagnostic CreateInvalidLookupIndexDiagnostic( + string filePath, + string displayPath, + string reason) + { + return Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidLookupIndexMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + displayPath, + LookupIndexMetadataKey, + reason); + } + /// /// 读取布尔元数据。 /// @@ -2114,6 +2136,16 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return (metadataElement.GetBoolean(), null); } + /// + /// 判断生成字段类型是否为引用类型。 + /// + /// 生成的 CLR 类型名。 + /// 引用类型时返回 true;否则返回 false + private static bool IsReferenceType(string clrType) + { + return string.Equals(clrType, "string", StringComparison.Ordinal); + } + /// /// 解析 schema 顶层配置目录元数据。 /// From 031d0d1e11cf1d771166a2925df07ed8add0b972 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:18:28 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(Config):=20=E6=9B=B4=E6=96=B0=20Schema?= =?UTF-8?q?ConfigGenerator=20=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 isDirectChildOfRoot 参数的文档说明 --- GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 38fe13bf..09a7845c 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -474,6 +474,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// Schema 文件路径。 /// Schema 原始字段名。 /// 逻辑字段路径。 + /// 字段是否直接从属于 schema 根对象。 /// 字段是否必填。 /// 可选的引用表名。 /// 不满足条件时输出的诊断。 From 017179466dba703bddd91fc7a291e223ddc05e19 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:33:12 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9E=B6=E6=9E=84=E7=94=9F=E6=88=90=E5=99=A8=E5=92=8C?= =?UTF-8?q?=E6=80=AA=E7=89=A9=E8=A1=A8=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 SchemaConfigGenerator 源代码生成器 - 自动生成 MonsterTable 配置表包装类 - 支持基于 JSON schema 的类型安全配置访问 - 生成配置表的精确匹配索引查找功能 - 实现懒加载的只读字典索引结构 - 添加配置实体的运行时注册和访问辅助方法 --- .../Config/SchemaConfigGeneratorTests.cs | 88 +++++++++++++++++++ .../SchemaConfigGenerator/MonsterTable.g.txt | 10 +++ .../Config/SchemaConfigGenerator.cs | 37 ++++++-- 3 files changed, 128 insertions(+), 7 deletions(-) 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}'.") + }; } ///