Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,17 @@ private void CreateMonsterFiles()
},
"name": {
"type": "string",
"description": "Monster display name."
"description": "Monster display name.",
"x-gframework-index": true
},
"hp": {
"type": "integer",
"description": "Monster base health."
},
"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
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions GFramework.Game.Tests/schemas/monster.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
},
"name": {
"type": "string",
"description": "Monster display name."
"description": "Monster display name.",
"x-gframework-index": true
},
"hp": {
"type": "integer",
"description": "Monster base health."
},
"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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
"type": "string",
"title": "Monster Name",
"description": "Localized monster display name.",
"x-gframework-index": true,
"minLength": 3,
"maxLength": 16,
"pattern": "^[A-Z][a-z]+$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,187 @@ public sealed class Dummy
});
}

/// <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>
Expand Down Expand Up @@ -429,7 +610,10 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"name": {
"type": "string",
"x-gframework-index": true
},
"hp": { "type": "integer" },
"dropItems": {
"type": "array",
Expand Down Expand Up @@ -468,8 +652,15 @@ public YamlConfigLoader RegisterTable<TKey, TValue>(
{
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("));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 />
Expand Down Expand Up @@ -53,28 +57,70 @@ 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>
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 (!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>();
if (value is null)
{
return global::System.Array.Empty<MonsterConfig>();
}

// 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<string>.Default.Equals(candidate.Name, value))
{
matches.Add(candidate);
}
return matches;
}

return matches.Count == 0 ? global::System.Array.Empty<MonsterConfig>() : matches.AsReadOnly();
return global::System.Array.Empty<MonsterConfig>();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// <summary>
Expand All @@ -84,18 +130,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;
Expand Down
1 change: 1 addition & 0 deletions GFramework.SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading