Merge pull request #195 from GeWuYou/feat/game-config-system

feat(config): 实现x-gframework-index索引元数据支持
This commit is contained in:
gewuyou 2026-04-08 12:48:39 +08:00 committed by GitHub
commit 015cac6eb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 817 additions and 62 deletions

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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]+$",

View File

@ -226,6 +226,94 @@ public class SchemaConfigGeneratorTests
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
}
/// <summary>
/// 验证生成的索引构建逻辑会跳过运行时空 key避免 Lazy 索引因格式错误数据永久失效。
/// </summary>
[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<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? 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."));
}
/// <summary>
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
/// </summary>
@ -267,6 +355,187 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证查询索引元数据必须是布尔值,避免 schema 作者误以为字符串或数字也会被解释为开关。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证查询索引元数据不能绑定到不满足约束的字段上,避免为嵌套字段生成误导性 API。
/// </summary>
[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"));
});
}
/// <summary>
/// 验证根对象直接字段即使 schema key 本身包含点号,也不会被错误识别为嵌套字段。
/// </summary>
[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<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? 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"));
}
/// <summary>
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
/// </summary>
@ -429,7 +698,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 +740,15 @@ 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("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"));
Assert.That(tableSource, Does.Not.Contain("FindById("));
Assert.That(tableSource, Does.Not.Contain("FindByDropItems("));
Assert.That(tableSource, Does.Not.Contain("FindByTargetId("));

View File

@ -10,6 +10,7 @@ namespace GFramework.Game.Config.Generated;
public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.Config.IConfigTable<int, MonsterConfig>
{
private readonly global::GFramework.Game.Abstractions.Config.IConfigTable<int, MonsterConfig> _inner;
private readonly global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<string, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>>> _nameIndex;
/// <summary>
/// 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<int, MonsterConfig> inner)
{
_inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));
_nameIndex = new global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<string, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>>>(
BuildNameIndex,
global::System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
}
/// <inheritdoc />
@ -53,28 +57,80 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
return _inner.All();
}
/// <summary>
/// Builds the exact-match lookup index declared for property 'name'.
/// </summary>
/// <returns>A read-only lookup index keyed by <c>Name</c>.</returns>
private global::System.Collections.Generic.IReadOnlyDictionary<string, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>> BuildNameIndex()
{
return BuildLookupIndex(static config => config.Name);
}
/// <summary>
/// Materializes a read-only exact-match lookup index from the current table snapshot.
/// </summary>
/// <typeparam name="TProperty">Indexed property type.</typeparam>
/// <param name="keySelector">Selects the indexed property from one config entry.</param>
/// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>
/// <remarks>
/// The generated index skips runtime null keys even though <typeparamref name="TProperty"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword="null" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.
/// </remarks>
private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>> BuildLookupIndex<TProperty>(
global::System.Func<MonsterConfig, TProperty> keySelector)
where TProperty : notnull
{
var buckets = new global::System.Collections.Generic.Dictionary<TProperty, global::System.Collections.Generic.List<MonsterConfig>>();
// 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 (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<MonsterConfig>();
buckets.Add(key, matches);
}
matches.Add(candidate);
}
var materialized = new global::System.Collections.Generic.Dictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>>(buckets.Count, buckets.Comparer);
foreach (var pair in buckets)
{
materialized.Add(pair.Key, pair.Value.AsReadOnly());
}
return new global::System.Collections.ObjectModel.ReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>>(materialized);
}
/// <summary>
/// Finds all config entries whose property 'name' equals the supplied value.
/// </summary>
/// <param name="value">The property value to match.</param>
/// <returns>A read-only snapshot containing every matching config entry.</returns>
/// <remarks>
/// The generated helper performs a deterministic linear scan over <see cref="All"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.
/// This property declares <c>x-gframework-index</c>, so the generated helper resolves matches through a lazily materialized read-only lookup index built from the current table snapshot.
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<MonsterConfig> FindByName(string value)
{
var matches = new global::System.Collections.Generic.List<MonsterConfig>();
// Scan the current table snapshot on demand so generated helpers stay aligned with reloadable runtime data.
foreach (var candidate in All())
if (value is null)
{
if (global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(candidate.Name, value))
{
matches.Add(candidate);
}
return global::System.Array.Empty<MonsterConfig>();
}
return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
if (_nameIndex.Value.TryGetValue(value, out var matches))
{
return matches;
}
return global::System.Array.Empty<MonsterConfig>();
}
/// <summary>
@ -84,18 +140,20 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
/// <param name="result">The first matching config entry when lookup succeeds; otherwise <see langword="null" />.</param>
/// <returns><see langword="true" /> when a matching config entry is found; otherwise <see langword="false" />.</returns>
/// <remarks>
/// The generated helper walks the same snapshot exposed by <see cref="All"/> and returns the first match in iteration order.
/// This property declares <c>x-gframework-index</c>, so the generated helper returns the first element from the lazily materialized exact-match bucket.
/// </remarks>
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 (value is null)
{
if (global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(candidate.Name, value))
{
result = candidate;
return true;
}
result = null;
return false;
}
if (_nameIndex.Value.TryGetValue(value, out var matches) && matches.Count > 0)
{
result = matches[0];
return true;
}
result = null;

View File

@ -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

View File

@ -11,7 +11,16 @@ 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";
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.";
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
@ -232,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);
@ -261,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)
@ -279,11 +290,38 @@ 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,
isDirectChildOfRoot,
isRequired,
refTableName,
out diagnostic))
{
return ParsedPropertyResult.FromDiagnostic(diagnostic!);
}
switch (schemaType)
{
case "integer":
@ -294,6 +332,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"integer",
@ -313,6 +352,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"number",
@ -332,6 +372,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"boolean",
@ -351,6 +392,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
isIndexedLookup,
new SchemaTypeSpec(
SchemaNodeKind.Scalar,
"string",
@ -364,6 +406,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
null)));
case "object":
if (isIndexedLookup)
{
return ParsedPropertyResult.FromDiagnostic(
CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage));
}
if (!string.IsNullOrWhiteSpace(refTableName))
{
return ParsedPropertyResult.FromDiagnostic(
@ -393,6 +441,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
false,
new SchemaTypeSpec(
SchemaNodeKind.Object,
"object",
@ -406,7 +455,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 +468,66 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
/// <summary>
/// 验证字段是否满足生成只读精确匹配索引的前提。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="schemaName">Schema 原始字段名。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="isDirectChildOfRoot">字段是否直接从属于 schema 根对象。</param>
/// <param name="isRequired">字段是否必填。</param>
/// <param name="refTableName">可选的引用表名。</param>
/// <param name="diagnostic">不满足条件时输出的诊断。</param>
/// <returns>当前字段是否允许声明只读索引。</returns>
private static bool TryValidateIndexedLookupEligibility(
string filePath,
string schemaName,
string displayPath,
bool isDirectChildOfRoot,
bool isRequired,
string? refTableName,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!isDirectChildOfRoot)
{
diagnostic = CreateInvalidLookupIndexDiagnostic(
filePath,
displayPath,
LookupIndexTopLevelScalarOnlyMessage);
return false;
}
if (!isRequired)
{
diagnostic = CreateInvalidLookupIndexDiagnostic(
filePath,
displayPath,
LookupIndexRequiresRequiredScalarMessage);
return false;
}
if (string.Equals(schemaName, "id", StringComparison.OrdinalIgnoreCase))
{
diagnostic = CreateInvalidLookupIndexDiagnostic(
filePath,
displayPath,
LookupIndexPrimaryKeyMessage);
return false;
}
if (!string.IsNullOrWhiteSpace(refTableName))
{
diagnostic = CreateInvalidLookupIndexDiagnostic(
filePath,
displayPath,
LookupIndexReferencePropertyMessage);
return false;
}
return true;
}
/// <summary>
/// 解析数组属性,支持标量数组与对象数组。
/// </summary>
@ -439,8 +548,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
string propertyName,
string? title,
string? description,
string? refTableName)
string? refTableName,
bool isIndexedLookup)
{
if (isIndexedLookup)
{
return ParsedPropertyResult.FromDiagnostic(
CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage));
}
if (!property.Value.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object ||
!itemsElement.TryGetProperty("type", out var itemTypeElement) ||
@ -477,6 +593,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
false,
new SchemaTypeSpec(
SchemaNodeKind.Array,
"array",
@ -528,6 +645,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
isRequired,
title,
description,
false,
new SchemaTypeSpec(
SchemaNodeKind.Array,
"array",
@ -587,6 +705,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("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
@ -603,6 +724,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<global::System.Collections.Generic.IReadOnlyDictionary<{property.TypeSpec.ClrType}, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>>> _{ToCamelCase(property.PropertyName)}Index;");
}
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Creates a generated table wrapper around the runtime config table instance.");
@ -612,6 +739,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<global::System.Collections.Generic.IReadOnlyDictionary<{property.TypeSpec.ClrType}, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>>>(");
builder.AppendLine($" Build{property.PropertyName}Index,");
builder.AppendLine(" global::System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);");
}
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <inheritdoc />");
@ -648,6 +783,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 +1495,95 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
}
/// <summary>
/// 为单个索引字段生成延迟构建器。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="schema">生成器级 schema 模型。</param>
/// <param name="property">声明了索引元数据的字段。</param>
private static void AppendIndexedLookupBuilderMethod(
StringBuilder builder,
SchemaFileSpec schema,
SchemaPropertySpec property)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// Builds the exact-match lookup index declared for property '{EscapeXmlDocumentation(property.DisplayPath)}'.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" /// <returns>A read-only lookup index keyed by <c>{EscapeXmlDocumentation(property.PropertyName)}</c>.</returns>");
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(" }");
}
/// <summary>
/// 为当前生成表输出共享索引构建逻辑。
/// </summary>
/// <param name="builder">输出缓冲区。</param>
/// <param name="schema">生成器级 schema 模型。</param>
private static void AppendSharedLookupIndexBuilderMethod(
StringBuilder builder,
SchemaFileSpec schema)
{
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Materializes a read-only exact-match lookup index from the current table snapshot.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <typeparam name=\"TProperty\">Indexed property type.</typeparam>");
builder.AppendLine(" /// <param name=\"keySelector\">Selects the indexed property from one config entry.</param>");
builder.AppendLine(" /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated index skips runtime null keys even though <typeparamref name=\"TProperty\"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword=\"null\" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
builder.AppendLine(" /// </remarks>");
builder.AppendLine(
$" private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>> BuildLookupIndex<TProperty>(");
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<TProperty, global::System.Collections.Generic.List<" +
$"{schema.ClassName}>>();");
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 (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}>();");
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<TProperty, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>>(buckets.Count, buckets.Comparer);");
builder.AppendLine(" foreach (var pair in buckets)");
builder.AppendLine(" {");
builder.AppendLine(
$" materialized.Add(pair.Key, pair.Value.AsReadOnly());");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" return new global::System.Collections.ObjectModel.ReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>>(materialized);");
builder.AppendLine(" }");
}
/// <summary>
/// 生成按字段匹配全部结果的轻量查询辅助。
/// </summary>
@ -1366,28 +1602,60 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// <param name=\"value\">The property value to match.</param>");
builder.AppendLine(" /// <returns>A read-only snapshot containing every matching config entry.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated helper performs a deterministic linear scan over <see cref=\"All\"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
if (property.IsIndexedLookup)
{
builder.AppendLine(
" /// This property declares <c>x-gframework-index</c>, 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 <see cref=\"All\"/> so it stays compatible with runtime hot reload and does not require secondary index infrastructure.");
}
builder.AppendLine(" /// </remarks>");
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)
{
if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{
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(" {");
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 +1680,61 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// <returns><see langword=\"true\" /> when a matching config entry is found; otherwise <see langword=\"false\" />.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated helper walks the same snapshot exposed by <see cref=\"All\"/> and returns the first match in iteration order.");
if (property.IsIndexedLookup)
{
builder.AppendLine(
" /// This property declares <c>x-gframework-index</c>, 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 <see cref=\"All\"/> and returns the first match in iteration order.");
}
builder.AppendLine(" /// </remarks>");
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)
{
if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{
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(" {");
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(" }");
}
@ -1731,6 +2034,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
}
/// <summary>
/// 将 PascalCase 标识符转换为 camelCase 字段名。
/// </summary>
/// <param name="value">PascalCase 标识符。</param>
/// <returns>适合作为私有字段名的 camelCase 标识符。</returns>
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);
}
/// <summary>
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
/// </summary>
@ -1784,6 +2107,69 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>
/// 创建统一格式的无效查询索引元数据诊断。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="reason">具体失败原因。</param>
/// <returns>稳定的查询索引诊断。</returns>
private static Diagnostic CreateInvalidLookupIndexDiagnostic(
string filePath,
string displayPath,
string reason)
{
return Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidLookupIndexMetadata,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
LookupIndexMetadataKey,
reason);
}
/// <summary>
/// 读取布尔元数据。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <param name="propertyName">元数据字段名。</param>
/// <returns>布尔元数据值;不存在时返回空。</returns>
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);
}
/// <summary>
/// 判断某个已支持的索引标量映射是否需要在查询辅助中生成空值守卫。
/// 这里必须显式枚举所有已支持的 schema 标量类型,避免未来新增引用类型标量时静默漏掉空检查。
/// </summary>
/// <param name="typeSpec">生成字段的标量类型模型。</param>
/// <returns>需要在生成的索引查询辅助中保护 <see langword="null" /> 参数时返回 <c>true</c>;否则返回 <c>false</c>。</returns>
/// <exception cref="InvalidOperationException">当前受支持的标量映射未被完整分类时抛出。</exception>
private static bool RequiresIndexedLookupNullGuard(SchemaTypeSpec typeSpec)
{
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}'.")
};
}
/// <summary>
/// 解析 schema 顶层配置目录元数据。
/// </summary>
@ -2210,6 +2596,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <param name="IsRequired">是否必填。</param>
/// <param name="Title">字段标题元数据。</param>
/// <param name="Description">字段描述元数据。</param>
/// <param name="IsIndexedLookup">是否声明生成只读精确匹配索引。</param>
/// <param name="TypeSpec">字段类型模型。</param>
private sealed record SchemaPropertySpec(
string SchemaName,
@ -2218,6 +2605,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
bool IsRequired,
string? Title,
string? Description,
bool IsIndexedLookup,
SchemaTypeSpec TypeSpec);
/// <summary>

View File

@ -85,4 +85,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 字段的查询索引元数据无效。
/// </summary>
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);
}

View File

@ -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()` 做线性扫描,不引入额外运行时索引成本
这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。