GFramework/GFramework.Game/Config/YamlConfigSchemaValidator.cs
gewuyou 13b77eb3fe fix(game-config): 显式声明闭合对象字段边界
- 修复 Runtime 与 Source Generator 对 additionalProperties 的隐式闭合对象语义,统一接受 additionalProperties:false 并拒绝其它开放对象形状

- 补充 Release 回归测试,覆盖生成器诊断与运行时 additionalProperties 边界

- 更新配置工具元数据与 README 说明,使命令、设置和当前能力描述保持一致
2026-04-30 13:23:19 +08:00

4101 lines
170 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using System.Text.RegularExpressions;
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。
/// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致,
/// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。
/// 当前共享子集额外支持 <c>multipleOf</c>、<c>uniqueItems</c>、
/// <c>contains</c> / <c>minContains</c> / <c>maxContains</c>、
/// <c>minProperties</c>、<c>maxProperties</c>、<c>dependentRequired</c>、
/// <c>dependentSchemas</c>、<c>allOf</c>、object-focused <c>if</c> / <c>then</c> / <c>else</c>
/// 与稳定字符串 <c>format</c> 子集,让数值步进、数组去重、数组匹配计数、
/// 对象属性数量、对象内字段依赖、条件对象子 schema、对象组合约束与条件分支约束
/// 在运行时与生成器 / 工具侧保持一致。
/// </summary>
internal static partial class YamlConfigSchemaValidator
{
// The runtime intentionally uses the same culture-invariant regex semantics as the
// JS tooling so grouping and backreferences behave consistently across environments.
private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant;
private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', 'uuid'";
private static readonly TimeSpan SupportedFormatRegexTimeout = TimeSpan.FromSeconds(1);
private static readonly Regex ExactDecimalPattern = new(
@"^(?<sign>[+-]?)(?:(?<integer>\d+)(?:\.(?<fraction>\d*))?|\.(?<fractionOnly>\d+))(?:[eE](?<exponent>[+-]?\d+))?$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedEmailFormatRegex = new(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedDateFormatRegex = new(
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedDateTimeFormatRegex = new(
@"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedDurationFormatRegex = new(
@"^P(?:(?<days>\d+)D)?(?:T(?:(?<hours>\d+)H)?(?:(?<minutes>\d+)M)?(?:(?<seconds>\d+(?:\.\d+)?)S)?)?$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedTimeFormatRegex = new(
@"^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
private static readonly Regex SupportedUriSchemeRegex = new(
@"^[A-Za-z][A-Za-z0-9+\.-]*:",
RegexOptions.CultureInvariant | RegexOptions.Compiled,
SupportedFormatRegexTimeout);
/// <summary>
/// 从磁盘加载并解析一个 JSON Schema 文件。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析后的 schema 模型。</returns>
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 schema 文件不存在或内容非法时抛出。</exception>
internal static async Task<YamlConfigSchema> LoadAsync(
string tableName,
string schemaPath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
if (string.IsNullOrWhiteSpace(schemaPath))
{
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
}
if (!File.Exists(schemaPath))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaFileNotFound,
tableName,
$"Schema file '{schemaPath}' was not found.",
schemaPath: schemaPath);
}
string schemaText;
try
{
schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaReadFailed,
tableName,
$"Failed to read schema file '{schemaPath}'.",
schemaPath: schemaPath,
innerException: exception);
}
return ParseLoadedSchema(tableName, schemaPath, schemaText);
}
/// <summary>
/// 从磁盘同步加载并解析一个 JSON Schema 文件。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <returns>解析后的 schema 模型。</returns>
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 schema 文件不存在或内容非法时抛出。</exception>
internal static YamlConfigSchema Load(
string tableName,
string schemaPath)
{
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
if (string.IsNullOrWhiteSpace(schemaPath))
{
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
}
if (!File.Exists(schemaPath))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaFileNotFound,
tableName,
$"Schema file '{schemaPath}' was not found.",
schemaPath: schemaPath);
}
string schemaText;
try
{
schemaText = File.ReadAllText(schemaPath);
}
catch (Exception exception)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaReadFailed,
tableName,
$"Failed to read schema file '{schemaPath}'.",
schemaPath: schemaPath,
innerException: exception);
}
return ParseLoadedSchema(tableName, schemaPath, schemaText);
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static void Validate(
string tableName,
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
ValidateCore(tableName, schema, yamlPath, yamlText, references: null);
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static IReadOnlyList<YamlConfigReferenceUsage> ValidateAndCollectReferences(
string tableName,
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
var references = new List<YamlConfigReferenceUsage>();
ValidateCore(tableName, schema, yamlPath, yamlText, references);
return references;
}
/// <summary>
/// 执行共享的 YAML 结构校验流程,并按需收集跨表引用。
/// 这样 <see cref="Validate" /> 可以复用同一条校验链路,同时避免为“不关心引用结果”的调用方分配临时列表。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <param name="references">可选的跨表引用收集器;为 <see langword="null" /> 时只做结构校验。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
private static void ValidateCore(
string tableName,
YamlConfigSchema schema,
string yamlPath,
string yamlText,
ICollection<YamlConfigReferenceUsage>? references)
{
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(yamlPath);
ArgumentNullException.ThrowIfNull(yamlText);
YamlStream yamlStream = new();
try
{
using var reader = new StringReader(yamlText);
yamlStream.Load(reader);
}
catch (Exception exception)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.YamlParseFailed,
tableName,
$"Config file '{yamlPath}' could not be parsed as YAML before schema validation.",
yamlPath: yamlPath,
schemaPath: schema.SchemaPath,
innerException: exception);
}
if (yamlStream.Documents.Count != 1)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.InvalidYamlDocument,
tableName,
$"Config file '{yamlPath}' must contain exactly one YAML document.",
yamlPath: yamlPath,
schemaPath: schema.SchemaPath);
}
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
}
/// <summary>
/// 解析已读取到内存中的 schema 文本,并构造运行时最小模型。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径,仅用于诊断信息。</param>
/// <param name="schemaText">Schema 文本内容。</param>
/// <returns>解析后的 schema 模型。</returns>
private static YamlConfigSchema ParseLoadedSchema(
string tableName,
string schemaPath,
string schemaText)
{
try
{
using var document = JsonDocument.Parse(schemaText);
var root = document.RootElement;
var rootNode = ParseNode(tableName, schemaPath, "<root>", root, isRoot: true);
if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Schema file '{schemaPath}' must declare a root object schema.",
schemaPath: schemaPath);
}
var referencedTableNames = new HashSet<string>(StringComparer.Ordinal);
CollectReferencedTableNames(rootNode, referencedTableNames);
// Preserve a deterministic dependency order so hot-reload bookkeeping and tests
// do not depend on HashSet enumeration details.
var orderedReferencedTableNames = referencedTableNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
return new YamlConfigSchema(schemaPath, rootNode, orderedReferencedTableNames);
}
catch (JsonException exception)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaInvalidJson,
tableName,
$"Schema file '{schemaPath}' contains invalid JSON.",
schemaPath: schemaPath,
innerException: exception);
}
}
/// <summary>
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">Schema JSON 节点。</param>
/// <param name="isRoot">是否为根节点。</param>
/// <returns>可用于运行时校验的节点模型。</returns>
private static YamlConfigSchemaNode ParseNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
bool isRoot = false)
{
ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element);
ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element);
var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element);
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName);
var parsedNode = CreateParsedNodeForType(
tableName,
schemaPath,
propertyPath,
element,
typeName,
referenceTableName,
isRoot);
return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element));
}
/// <summary>
/// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。
/// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败,
/// 而不是默默忽略 <c>oneOf</c> / <c>anyOf</c> 造成接受范围漂移。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">当前 schema 节点。</param>
private static void ValidateUnsupportedCombinatorKeywords(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported combinator keyword '{keywordName}'. " +
"The current config schema subset does not support combinators that can change generated type shape.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
/// <summary>
/// 显式拒绝当前共享子集中尚未支持的开放对象关键字形状。
/// 当前配置系统默认采用闭合对象字段集;这里只接受显式重复该语义的
/// <c>additionalProperties: false</c>,继续拒绝会引入动态字段形状的其它形式。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">当前 schema 节点。</param>
private static void ValidateUnsupportedOpenObjectKeywords(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement))
{
return;
}
if (additionalPropertiesElement.ValueKind == JsonValueKind.False)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported 'additionalProperties' metadata. " +
"The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
/// <summary>
/// 返回当前节点声明的首个未支持组合关键字。
/// </summary>
/// <param name="element">当前 schema 节点。</param>
/// <returns>命中的关键字名称;未声明时返回空。</returns>
private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element)
{
return element.TryGetProperty("oneOf", out _) ? "oneOf" :
element.TryGetProperty("anyOf", out _) ? "anyOf" :
null;
}
/// <summary>
/// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。
/// </summary>
private static string ResolveNodeTypeName(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("type", out var typeElement) ||
typeElement.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return typeElement.GetString() ?? string.Empty;
}
/// <summary>
/// 限制只允许对象 schema 使用对象专属关键字,避免后续分支在运行时才发现语义不兼容。
/// </summary>
private static void ValidateObjectOnlyKeywords(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
string typeName)
{
if (string.Equals(typeName, "object", StringComparison.Ordinal) ||
TryGetObjectOnlyKeywordName(element) is not { } objectOnlyKeywordName)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' can only declare '{objectOnlyKeywordName}' on object schemas.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
/// <summary>
/// 根据声明的 schema 类型分派到对应的节点解析器。
/// </summary>
private static YamlConfigSchemaNode CreateParsedNodeForType(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
string typeName,
string? referenceTableName,
bool isRoot)
{
return typeName switch
{
"object" => ParseObjectSchemaNode(
tableName,
schemaPath,
propertyPath,
element,
referenceTableName,
isRoot),
"array" => ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName),
"integer" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Integer,
element,
referenceTableName),
"number" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Number,
element,
referenceTableName),
"boolean" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Boolean,
element,
referenceTableName),
"string" => CreateScalarNode(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.String,
element,
referenceTableName),
_ => throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
rawValue: typeName)
};
}
/// <summary>
/// 解析对象类型 schema并在进入对象节点解析前先校验 ref-table 是否兼容。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象属性路径。</param>
/// <param name="element">对象 schema 节点。</param>
/// <param name="referenceTableName">声明在当前节点上的目标引用表。</param>
/// <param name="isRoot">是否为根节点。</param>
/// <returns>对象节点模型。</returns>
private static YamlConfigSchemaNode ParseObjectSchemaNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
string? referenceTableName,
bool isRoot)
{
EnsureReferenceKeywordIsSupported(
tableName,
schemaPath,
propertyPath,
YamlConfigSchemaPropertyType.Object,
referenceTableName);
return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot);
}
/// <summary>
/// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象属性路径。</param>
/// <param name="element">对象 schema 节点。</param>
/// <param name="isRoot">是否为根节点。</param>
/// <returns>对象节点模型。</returns>
private static YamlConfigSchemaNode ParseObjectNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
bool isRoot)
{
if (!element.TryGetProperty("properties", out var propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object)
{
var subject = isRoot ? "root schema" : $"object property '{propertyPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
if (element.TryGetProperty("required", out var requiredElement) &&
requiredElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in requiredElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
{
continue;
}
var requiredPropertyName = item.GetString();
if (!string.IsNullOrWhiteSpace(requiredPropertyName))
{
requiredProperties.Add(requiredPropertyName);
}
}
}
var properties = new Dictionary<string, YamlConfigSchemaNode>(StringComparer.Ordinal);
foreach (var property in propertiesElement.EnumerateObject())
{
properties[property.Name] = ParseNode(
tableName,
schemaPath,
CombineSchemaPath(propertyPath, property.Name),
property.Value);
}
var objectNode = YamlConfigSchemaNode.CreateObject(
properties,
requiredProperties,
ParseObjectConstraints(tableName, schemaPath, propertyPath, element, properties),
schemaPath);
objectNode = objectNode.WithAllowedValues(
ParseEnumValues(tableName, schemaPath, propertyPath, element, objectNode, "enum"));
return objectNode.WithConstantValue(
ParseConstantValue(tableName, schemaPath, propertyPath, element, objectNode));
}
/// <summary>
/// 解析数组节点。
/// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。
/// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组属性路径。</param>
/// <param name="element">数组 schema 节点。</param>
/// <param name="referenceTableName">声明在数组节点上的目标引用表。</param>
/// <returns>数组节点模型。</returns>
private static YamlConfigSchemaNode ParseArrayNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
string? referenceTableName)
{
if (!element.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var itemNode = ParseNode(tableName, schemaPath, $"{propertyPath}[]", itemsElement);
if (!string.IsNullOrWhiteSpace(referenceTableName))
{
if (itemNode.NodeType != YamlConfigSchemaPropertyType.String &&
itemNode.NodeType != YamlConfigSchemaPropertyType.Integer)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
referencedTableName: referenceTableName);
}
itemNode = itemNode.WithReferenceTable(referenceTableName);
}
if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var arrayNode = YamlConfigSchemaNode.CreateArray(
itemNode,
allowedValues: null,
ParseArrayConstraints(tableName, schemaPath, propertyPath, element),
schemaPath);
arrayNode = arrayNode.WithAllowedValues(
ParseEnumValues(tableName, schemaPath, propertyPath, element, arrayNode, "enum"));
return arrayNode.WithConstantValue(
ParseConstantValue(tableName, schemaPath, propertyPath, element, arrayNode));
}
/// <summary>
/// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">标量属性路径。</param>
/// <param name="nodeType">标量类型。</param>
/// <param name="element">标量 schema 节点。</param>
/// <param name="referenceTableName">目标引用表名称。</param>
/// <returns>标量节点模型。</returns>
private static YamlConfigSchemaNode CreateScalarNode(
string tableName,
string schemaPath,
string propertyPath,
YamlConfigSchemaPropertyType nodeType,
JsonElement element,
string? referenceTableName)
{
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName);
var scalarNode = YamlConfigSchemaNode.CreateScalar(
nodeType,
referenceTableName,
allowedValues: null,
ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType),
schemaPath);
scalarNode = scalarNode.WithAllowedValues(
ParseEnumValues(tableName, schemaPath, propertyPath, element, scalarNode, "enum"));
return scalarNode.WithConstantValue(
ParseConstantValue(tableName, schemaPath, propertyPath, element, scalarNode));
}
/// <summary>
/// 解析节点上的 <c>not</c> 约束。
/// 该子 schema 继续复用同一套节点解析逻辑,保证 Runtime / Generator / Tooling
/// 对深层结构与格式白名单的解释保持一致。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点路径。</param>
/// <param name="element">Schema JSON 节点。</param>
/// <returns>解析后的 negated schema未声明时返回空。</returns>
private static YamlConfigSchemaNode? ParseNegatedSchemaNode(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("not", out var notElement))
{
return null;
}
if (notElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'not' as an object-valued schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return ParseNode(
tableName,
schemaPath,
BuildNestedSchemaPath(propertyPath, "not"),
notElement);
}
/// <summary>
/// 为 <c>contains</c> / <c>not</c> / <c>dependentSchemas</c> 这类内联子 schema 构建稳定的诊断路径。
/// </summary>
/// <param name="propertyPath">当前节点路径。</param>
/// <param name="suffix">内联子 schema 后缀。</param>
/// <returns>带内联后缀的 schema 路径。</returns>
private static string BuildNestedSchemaPath(string propertyPath, string suffix)
{
return string.IsNullOrWhiteSpace(propertyPath)
? $"[{suffix}]"
: $"{propertyPath}[{suffix}]";
}
/// <summary>
/// 递归校验 YAML 节点。
/// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前字段路径;根节点时为空。</param>
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对应的 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许对象节点出现当前 schema 子树未声明的额外字段。
/// 该开关仅用于 <c>contains</c> 试匹配,让对象子 schema 可以按“声明属性子集匹配”工作;
/// 正常加载主链路仍保持未知字段即失败的严格语义。
/// </param>
private static void ValidateNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties = false)
{
switch (schemaNode.NodeType)
{
case YamlConfigSchemaPropertyType.Object:
ValidateObjectNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode,
references,
allowUnknownObjectProperties);
return;
case YamlConfigSchemaPropertyType.Array:
ValidateArrayNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode,
references,
allowUnknownObjectProperties);
return;
case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number:
case YamlConfigSchemaPropertyType.Boolean:
case YamlConfigSchemaPropertyType.String:
ValidateScalarNode(tableName, yamlPath, displayPath, node, schemaNode, references);
return;
default:
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: schemaNode.NodeType.ToString());
}
}
/// <summary>
/// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前对象的逻辑字段路径。</param>
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许当前对象包含 schema 子树未声明的额外字段。
/// </param>
private static void ValidateObjectNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
var mappingNode = GetObjectMappingNode(tableName, yamlPath, displayPath, node, schemaNode);
var seenProperties = ValidateObjectPropertyEntries(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
references,
allowUnknownObjectProperties);
ValidateRequiredObjectProperties(tableName, yamlPath, displayPath, schemaNode, seenProperties);
ValidateObjectConstraints(
tableName,
yamlPath,
displayPath,
mappingNode,
seenProperties,
schemaNode,
references);
ValidateAllowedValues(tableName, yamlPath, displayPath, mappingNode, schemaNode);
ValidateConstantValue(tableName, yamlPath, displayPath, mappingNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, mappingNode, schemaNode);
}
/// <summary>
/// 确认当前 YAML 节点确实是对象节点,避免后续属性枚举阶段再做重复判空与类型判断。
/// </summary>
private static YamlMappingNode GetObjectMappingNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
if (node is YamlMappingNode mappingNode)
{
return mappingNode;
}
var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.PropertyTypeMismatch,
tableName,
$"{subject} in config file '{yamlPath}' must be an object.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
/// <summary>
/// 遍历对象属性并递归校验每个已声明字段,同时记录用于后续 required 与 dependency 判断的 sibling 集合。
/// </summary>
private static HashSet<string> ValidateObjectPropertyEntries(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in mappingNode.Children)
{
if (entry.Key is not YamlScalarNode keyNode ||
string.IsNullOrWhiteSpace(keyNode.Value))
{
var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.InvalidYamlDocument,
tableName,
$"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
var propertyName = keyNode.Value;
var propertyPath = CombineDisplayPath(displayPath, propertyName);
if (!seenProperties.Add(propertyName))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.DuplicateProperty,
tableName,
$"Config file '{yamlPath}' contains duplicate property '{propertyPath}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: propertyPath);
}
if (schemaNode.Properties is null ||
!schemaNode.Properties.TryGetValue(propertyName, out var propertySchema))
{
if (allowUnknownObjectProperties)
{
continue;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnknownProperty,
tableName,
$"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: propertyPath);
}
ValidateNode(
tableName,
yamlPath,
propertyPath,
entry.Value,
propertySchema,
references,
allowUnknownObjectProperties);
}
return seenProperties;
}
/// <summary>
/// 在对象主体字段遍历结束后统一检查缺失的 required 字段,保证错误消息使用稳定的完整路径。
/// </summary>
private static void ValidateRequiredObjectProperties(
string tableName,
string yamlPath,
string displayPath,
YamlConfigSchemaNode schemaNode,
HashSet<string> seenProperties)
{
if (schemaNode.RequiredProperties is null)
{
return;
}
foreach (var requiredProperty in schemaNode.RequiredProperties)
{
if (seenProperties.Contains(requiredProperty))
{
continue;
}
var requiredPath = CombineDisplayPath(displayPath, requiredProperty);
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.MissingRequiredProperty,
tableName,
$"Config file '{yamlPath}' is missing required property '{requiredPath}' defined by schema '{schemaNode.SchemaPathHint}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: requiredPath);
}
}
/// <summary>
/// 校验对象节点声明的数量约束与条件对象约束。
/// 该阶段除了检查 <c>minProperties</c> / <c>maxProperties</c>,还会复用同一份 sibling 集合处理
/// <c>dependentRequired</c>,并在 <c>dependentSchemas</c> 命中时以 focused constraint block 语义
/// 对整个 <paramref name="mappingNode" /> 做额外试匹配;若声明了 object-focused
/// <c>if</c> / <c>then</c> / <c>else</c>,则先按同样的 focused matcher 判断条件分支,再只对命中的分支追加约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">对象字段路径;根对象时为空。</param>
/// <param name="mappingNode">当前 YAML 对象节点;用于让条件子 schema 在完整对象视图上做匹配。</param>
/// <param name="seenProperties">当前对象已出现的属性集合。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">
/// 可选的跨表引用收集器;当 <c>dependentSchemas</c>、<c>allOf</c> 或条件分支命中且匹配成功时,
/// 只会回写对应内联分支新增的引用。
/// </param>
private static void ValidateObjectConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
HashSet<string> seenProperties,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
{
var constraints = schemaNode.ObjectConstraints;
if (constraints is null)
{
return;
}
var propertyCount = seenProperties.Count;
var subject = GetObjectConstraintSubject(displayPath);
ValidateObjectPropertyCountConstraints(
tableName,
yamlPath,
displayPath,
schemaNode,
constraints,
subject,
propertyCount);
ValidateDependentRequiredConstraints(
tableName,
yamlPath,
displayPath,
schemaNode,
constraints,
seenProperties);
ValidateDependentSchemas(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
constraints,
references,
seenProperties,
subject);
ValidateAllOfSchemas(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
constraints,
references,
subject);
ValidateConditionalObjectSchemas(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
constraints,
references,
subject);
}
/// <summary>
/// 为对象约束构造统一的诊断主语,保证根对象与嵌套对象的错误消息格式一致。
/// </summary>
private static string GetObjectConstraintSubject(string displayPath)
{
return string.IsNullOrWhiteSpace(displayPath)
? "Root object"
: $"Property '{displayPath}'";
}
/// <summary>
/// 校验对象属性数量上下限。
/// </summary>
private static void ValidateObjectPropertyCountConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlConfigSchemaNode schemaNode,
YamlConfigObjectConstraints constraints,
string subject,
int propertyCount)
{
var rawValue = propertyCount.ToString(CultureInfo.InvariantCulture);
if (constraints.MinProperties.HasValue &&
propertyCount < constraints.MinProperties.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must contain at least {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.MaxProperties.HasValue &&
propertyCount > constraints.MaxProperties.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must contain at most {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
}
}
/// <summary>
/// 使用已见 sibling 集合校验 dependentRequired确保对象主路径与试匹配路径共用同一判定语义。
/// </summary>
private static void ValidateDependentRequiredConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlConfigSchemaNode schemaNode,
YamlConfigObjectConstraints constraints,
HashSet<string> seenProperties)
{
if (constraints.DependentRequired is null ||
constraints.DependentRequired.Count == 0)
{
return;
}
// Reuse the collected sibling-name set so the main validation path and
// the contains/not matcher both interpret object dependencies identically.
foreach (var dependency in constraints.DependentRequired)
{
if (!seenProperties.Contains(dependency.Key))
{
continue;
}
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
foreach (var dependentProperty in dependency.Value)
{
if (seenProperties.Contains(dependentProperty))
{
continue;
}
var requiredPath = CombineDisplayPath(displayPath, dependentProperty);
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.MissingRequiredProperty,
tableName,
$"Property '{requiredPath}' in config file '{yamlPath}' is required when sibling property '{triggerPath}' is present.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: requiredPath,
detail:
$"Dependent requirement: when '{triggerPath}' exists, '{requiredPath}' must also be declared.");
}
}
}
/// <summary>
/// 在触发字段出现时,以 focused matcher 语义试跑 dependentSchemas。
/// </summary>
private static void ValidateDependentSchemas(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
YamlConfigSchemaNode schemaNode,
YamlConfigObjectConstraints constraints,
ICollection<YamlConfigReferenceUsage>? references,
HashSet<string> seenProperties,
string subject)
{
if (constraints.DependentSchemas is null ||
constraints.DependentSchemas.Count == 0)
{
return;
}
foreach (var dependency in constraints.DependentSchemas)
{
if (!seenProperties.Contains(dependency.Key))
{
continue;
}
var triggerPath = CombineDisplayPath(displayPath, dependency.Key);
// dependentSchemas acts as an additional conditional constraint block on the
// current object. Keep undeclared sibling fields outside the dependent sub-schema
// from blocking the match so schema authors can express focused follow-up rules.
// The trial matcher merges only new reference usages back into the outer collector,
// so re-checking the same scalar via a conditional sub-schema does not duplicate
// cross-table validation work later in the loader pipeline.
if (TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
dependency.Value,
references,
allowUnknownObjectProperties: true))
{
continue;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy the 'dependentSchemas' schema triggered by sibling property '{triggerPath}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
detail:
$"Dependent schema: when '{triggerPath}' exists, the current object must satisfy the corresponding inline schema.");
}
}
/// <summary>
/// 逐条校验 allOf 约束,保持与 dependentSchemas 相同的 focused object 匹配语义。
/// </summary>
private static void ValidateAllOfSchemas(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
YamlConfigSchemaNode schemaNode,
YamlConfigObjectConstraints constraints,
ICollection<YamlConfigReferenceUsage>? references,
string subject)
{
if (constraints.AllOfSchemas is null ||
constraints.AllOfSchemas.Count == 0)
{
return;
}
for (var index = 0; index < constraints.AllOfSchemas.Count; index++)
{
var allOfSchema = constraints.AllOfSchemas[index];
// allOf follows the same focused constraint block semantics as dependentSchemas:
// the inline schema may validate a subset of the current object without forcing
// unrelated sibling fields to be restated.
if (TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
allOfSchema,
references,
allowUnknownObjectProperties: true))
{
continue;
}
var allOfEntryNumber = index + 1;
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy all 'allOf' schemas, but entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} did not match.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
detail:
$"allOf entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} must match the current object.");
}
}
/// <summary>
/// 执行对象级 if/then/else 约束,并沿用 focused matcher 允许条件 schema 只声明关注字段。
/// </summary>
private static void ValidateConditionalObjectSchemas(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
YamlConfigSchemaNode schemaNode,
YamlConfigObjectConstraints constraints,
ICollection<YamlConfigReferenceUsage>? references,
string subject)
{
var conditionalSchemas = constraints.ConditionalSchemas;
if (conditionalSchemas is null)
{
return;
}
// if/then/else follows the same object-focused matcher contract as dependentSchemas/allOf:
// condition evaluation can inspect a subset of the current object without forcing unrelated
// sibling fields to be re-declared inside the branch schema.
var ifMatched = TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
conditionalSchemas.IfSchema,
references,
allowUnknownObjectProperties: true);
ValidateConditionalSchemaBranch(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
references,
subject,
ifMatched,
conditionalSchemas.ThenSchema,
branchName: "then",
failureDetail:
"Conditional schema: the current object matched the inline 'if' schema, so it must also satisfy the corresponding 'then' schema.");
ValidateConditionalSchemaBranch(
tableName,
yamlPath,
displayPath,
mappingNode,
schemaNode,
references,
subject,
!ifMatched,
conditionalSchemas.ElseSchema,
branchName: "else",
failureDetail:
"Conditional schema: the current object did not match the inline 'if' schema, so it must satisfy the corresponding 'else' schema.");
}
/// <summary>
/// 校验 if/then/else 的单个分支,并在条件命中但分支未通过时提供统一诊断。
/// </summary>
private static void ValidateConditionalSchemaBranch(
string tableName,
string yamlPath,
string displayPath,
YamlMappingNode mappingNode,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
string subject,
bool shouldValidate,
YamlConfigSchemaNode? branchSchema,
string branchName,
string failureDetail)
{
if (!shouldValidate ||
branchSchema is null ||
TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
mappingNode,
branchSchema,
references,
allowUnknownObjectProperties: true))
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must satisfy the '{branchName}' schema because the inline 'if' condition {(string.Equals(branchName, "then", StringComparison.Ordinal) ? "matched" : "did not match")}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
detail: failureDetail);
}
/// <summary>
/// 校验数组节点,并递归验证每个元素。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">数组字段路径。</param>
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
/// <param name="allowUnknownObjectProperties">
/// 是否允许数组元素内的对象节点包含 schema 子树未声明的额外字段。
/// </param>
private static void ValidateArrayNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
if (node is not YamlSequenceNode sequenceNode)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.PropertyTypeMismatch,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be an array.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
if (schemaNode.ItemNode is null)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Schema node '{displayPath}' is missing array item information.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
if (schemaNode.ArrayConstraints is not null)
{
ValidateArrayConstraints(tableName, yamlPath, displayPath, sequenceNode.Children.Count, schemaNode);
}
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{
ValidateNode(
tableName,
yamlPath,
$"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex],
schemaNode.ItemNode,
references,
allowUnknownObjectProperties);
}
ValidateArrayUniqueItemsConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateArrayContainsConstraints(tableName, yamlPath, displayPath, sequenceNode, schemaNode, references);
ValidateAllowedValues(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateConstantValue(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, sequenceNode, schemaNode);
}
/// <summary>
/// 校验标量节点,并在值有效时收集跨表引用。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">标量字段路径。</param>
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param>
private static void ValidateScalarNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
{
var scalarNode = GetScalarNodeOrThrow(tableName, yamlPath, displayPath, node, schemaNode);
var value = GetScalarValueOrThrow(tableName, yamlPath, displayPath, scalarNode, schemaNode);
ValidateScalarTypeOrThrow(tableName, yamlPath, displayPath, scalarNode, schemaNode, value);
var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value);
ValidateAllowedValues(tableName, yamlPath, displayPath, scalarNode, schemaNode);
if (schemaNode.Constraints is not null)
{
ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode);
}
ValidateConstantValue(tableName, yamlPath, displayPath, scalarNode, schemaNode);
ValidateNegatedSchemaConstraint(tableName, yamlPath, displayPath, scalarNode, schemaNode);
CollectScalarReferenceUsage(yamlPath, displayPath, schemaNode, references, normalizedValue);
}
/// <summary>
/// 确认 schema 期望的节点在 YAML 中仍然表现为标量。
/// </summary>
private static YamlScalarNode GetScalarNodeOrThrow(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
if (node is YamlScalarNode scalarNode)
{
return scalarNode;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.PropertyTypeMismatch,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
/// <summary>
/// 读取标量文本值,并把 YAML 显式 null 与普通空字符串区分开。
/// </summary>
private static string GetScalarValueOrThrow(
string tableName,
string yamlPath,
string displayPath,
YamlScalarNode scalarNode,
YamlConfigSchemaNode schemaNode)
{
if (scalarNode.Value is { } value)
{
return value;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.NullScalarValue,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
}
/// <summary>
/// 按 schema 声明的标量类型验证 YAML 文本值。
/// </summary>
private static void ValidateScalarTypeOrThrow(
string tableName,
string yamlPath,
string displayPath,
YamlScalarNode scalarNode,
YamlConfigSchemaNode schemaNode,
string value)
{
var tag = scalarNode.Tag.ToString();
var isValid = schemaNode.NodeType switch
{
YamlConfigSchemaPropertyType.String => IsStringScalar(tag),
YamlConfigSchemaPropertyType.Integer => long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out _),
YamlConfigSchemaPropertyType.Number => double.TryParse(
value,
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out _),
YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _),
_ => false
};
if (isValid)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.PropertyTypeMismatch,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: value);
}
/// <summary>
/// 在标量值成功通过本地校验后,再把引用信息回写给外层收集器。
/// </summary>
private static void CollectScalarReferenceUsage(
string yamlPath,
string displayPath,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
string normalizedValue)
{
if (schemaNode.ReferenceTableName is null ||
references is null)
{
return;
}
references.Add(
new YamlConfigReferenceUsage(
yamlPath,
schemaNode.SchemaPathHint,
displayPath,
normalizedValue,
schemaNode.ReferenceTableName,
schemaNode.NodeType));
}
/// <summary>
/// 解析 <c>enum</c>,并在读取阶段将每个候选值预归一化成与运行时 YAML 相同的稳定比较键。
/// 这样对象、数组和标量节点都可以复用同一套匹配逻辑,而不必在每次加载时重新解释 schema 字面量。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaNode">当前节点的已解析 schema 模型。</param>
/// <param name="keywordName">当前读取的关键字名称。</param>
/// <returns>归一化后的允许值集合;未声明时返回空。</returns>
private static IReadOnlyCollection<YamlConfigAllowedValue>? ParseEnumValues(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaNode schemaNode,
string keywordName)
{
if (!element.TryGetProperty("enum", out var enumElement))
{
return null;
}
if (enumElement.ValueKind != JsonValueKind.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var allowedValues = new List<YamlConfigAllowedValue>();
foreach (var item in enumElement.EnumerateArray())
{
allowedValues.Add(
new YamlConfigAllowedValue(
BuildComparableConstantValue(tableName, schemaPath, propertyPath, keywordName, item, schemaNode),
BuildEnumDisplayValue(item)));
}
return allowedValues;
}
/// <summary>
/// 读取一个 <c>enum</c> 候选值的展示文本。
/// 这里直接保留原始 JSON 字面量,避免字符串引号、对象字段顺序或数组结构在诊断中被二次格式化后丢失上下文。
/// </summary>
/// <param name="element">Schema 中的单个枚举值。</param>
/// <returns>适合放入诊断消息的展示文本。</returns>
private static string BuildEnumDisplayValue(JsonElement element)
{
return element.GetRawText();
}
/// <summary>
/// 解析 <c>const</c>,并把 schema 常量预归一化成与运行时 YAML 相同的稳定比较键。
/// 这样运行时只需要复用现有递归比较逻辑,而不必在每次加载时重新解释 JSON 常量。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="schemaNode">已解析的 schema 节点。</param>
/// <returns>常量约束模型;未声明时返回空。</returns>
private static YamlConfigConstantValue? ParseConstantValue(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaNode schemaNode)
{
if (!element.TryGetProperty("const", out var constantElement))
{
return null;
}
return new YamlConfigConstantValue(
BuildComparableConstantValue(tableName, schemaPath, propertyPath, "const", constantElement, schemaNode),
constantElement.GetRawText());
}
/// <summary>
/// 把 schema 中的 <c>const</c> JSON 值转换成与 YAML 运行时一致的比较键。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param>
/// <param name="element">常量 JSON 值。</param>
/// <param name="schemaNode">目标 schema 节点。</param>
/// <returns>可稳定比较的归一化键。</returns>
private static string BuildComparableConstantValue(
string tableName,
string schemaPath,
string propertyPath,
string keywordName,
JsonElement element,
YamlConfigSchemaNode schemaNode)
{
return schemaNode.NodeType switch
{
YamlConfigSchemaPropertyType.Object => BuildComparableConstantObjectValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
YamlConfigSchemaPropertyType.Array => BuildComparableConstantArrayValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
YamlConfigSchemaPropertyType.Integer => BuildComparableConstantScalarValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
YamlConfigSchemaPropertyType.Number => BuildComparableConstantScalarValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
YamlConfigSchemaPropertyType.Boolean => BuildComparableConstantScalarValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
YamlConfigSchemaPropertyType.String => BuildComparableConstantScalarValue(
tableName,
schemaPath,
propertyPath,
keywordName,
element,
schemaNode),
_ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.")
};
}
/// <summary>
/// 构建对象常量的稳定比较键。
/// 这里同样忽略 JSON 对象字段顺序,避免 schema 文本格式影响常量比较结果。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param>
/// <param name="element">常量 JSON 值。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <returns>对象常量的可比较键。</returns>
private static string BuildComparableConstantObjectValue(
string tableName,
string schemaPath,
string propertyPath,
string keywordName,
JsonElement element,
YamlConfigSchemaNode schemaNode)
{
if (element.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only object values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var properties = schemaNode.Properties
?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
var objectEntries = new List<KeyValuePair<string, string>>();
foreach (var property in element.EnumerateObject())
{
if (!properties.TryGetValue(property.Name, out var propertySchema))
{
var childPath = CombineSchemaPath(propertyPath, property.Name);
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but nested property '{childPath}' is not declared in the object schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(childPath));
}
objectEntries.Add(
new KeyValuePair<string, string>(
property.Name,
BuildComparableConstantValue(
tableName,
schemaPath,
CombineSchemaPath(propertyPath, property.Name),
keywordName,
property.Value,
propertySchema)));
}
objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
return string.Join(
"|",
objectEntries.Select(static entry =>
$"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}"));
}
/// <summary>
/// 构建数组常量的稳定比较键。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param>
/// <param name="element">常量 JSON 值。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <returns>数组常量的可比较键。</returns>
private static string BuildComparableConstantArrayValue(
string tableName,
string schemaPath,
string propertyPath,
string keywordName,
JsonElement element,
YamlConfigSchemaNode schemaNode)
{
if (element.ValueKind != JsonValueKind.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only array values are compatible with schema type '{GetTypeName(schemaNode.NodeType)}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (schemaNode.ItemNode is null)
{
throw new InvalidOperationException("Array schema nodes must expose their item schema.");
}
return "[" +
string.Join(
",",
element.EnumerateArray().Select((item, index) =>
{
var comparableValue = BuildComparableConstantValue(
tableName,
schemaPath,
$"{propertyPath}[{index}]",
keywordName,
item,
schemaNode.ItemNode);
return
$"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
})) +
"]";
}
/// <summary>
/// 构建标量常量的稳定比较键。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param>
/// <param name="element">常量 JSON 值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <returns>标量常量的可比较键。</returns>
private static string BuildComparableConstantScalarValue(
string tableName,
string schemaPath,
string propertyPath,
string keywordName,
JsonElement element,
YamlConfigSchemaNode schemaNode)
{
var normalizedValue = NormalizeKeywordScalarValue(
tableName,
schemaPath,
propertyPath,
keywordName,
schemaNode.NodeType,
element);
return
$"{schemaNode.NodeType}:{normalizedValue.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedValue}";
}
/// <summary>
/// 解析标量字段支持的范围、长度与模式约束。
/// 当前共享子集支持:
/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`
/// 以及 `string` 上的 `minLength/maxLength/pattern/format`。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">标量类型。</param>
/// <returns>解析后的约束模型;未声明时返回空。</returns>
private static YamlConfigScalarConstraints? ParseScalarConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum");
var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum");
var exclusiveMinimum =
TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum");
var exclusiveMaximum =
TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum");
var multipleOf = TryParseMultipleOfConstraint(tableName, schemaPath, propertyPath, element, nodeType);
var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength");
var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength");
var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType);
var formatConstraint = TryParseFormatConstraint(tableName, schemaPath, propertyPath, element, nodeType);
if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minimum' greater than 'maximum'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
ValidateNumericConstraintRange(
tableName,
schemaPath,
propertyPath,
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum);
if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minLength' greater than 'maxLength'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var numericConstraints = CreateNumericScalarConstraints(
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum,
multipleOf);
var stringConstraints = CreateStringScalarConstraints(
minLength,
maxLength,
pattern,
formatConstraint);
return numericConstraints is null && stringConstraints is null
? null
: new YamlConfigScalarConstraints(numericConstraints, stringConstraints);
}
/// <summary>
/// 解析数组节点支持的元素数量、去重与 <c>contains</c> 匹配数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>数组约束模型;未声明时返回空。</returns>
private static YamlConfigArrayConstraints? ParseArrayConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems");
var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems");
var uniqueItems = TryParseUniqueItemsConstraint(tableName, schemaPath, propertyPath, element);
var containsConstraints = ParseArrayContainsConstraints(tableName, schemaPath, propertyPath, element);
if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minItems' greater than 'maxItems'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return !minItems.HasValue && !maxItems.HasValue && !uniqueItems && containsConstraints is null
? null
: new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems, containsConstraints);
}
/// <summary>
/// 解析数组节点声明的 <c>contains</c> 约束及其匹配数量边界。
/// 运行时会把 <c>contains</c> 解析成独立的 schema 子树,后续逐项复用同一套递归校验逻辑判断“是否匹配”。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>数组 contains 约束模型;未声明时返回空。</returns>
private static YamlConfigArrayContainsConstraints? ParseArrayContainsConstraints(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
var minContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minContains");
var maxContains = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxContains");
if (!element.TryGetProperty("contains", out var containsElement))
{
if (minContains.HasValue || maxContains.HasValue)
{
var keywordName = minContains.HasValue ? "minContains" : "maxContains";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares '{keywordName}' without a companion 'contains' schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return null;
}
if (containsElement.ValueKind != JsonValueKind.Object)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'contains' as an object-valued schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var containsNode = ParseNode(tableName, schemaPath, $"{propertyPath}[contains]", containsElement);
if (containsNode.NodeType == YamlConfigSchemaPropertyType.Array)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array 'contains' schemas.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var effectiveMinContains = minContains ?? 1;
if (maxContains.HasValue && effectiveMinContains > maxContains.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minContains' greater than 'maxContains'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return new YamlConfigArrayContainsConstraints(containsNode, minContains, maxContains);
}
/// <summary>
/// 读取数值区间约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>数值约束;未声明时返回空。</returns>
private static double? TryParseNumericConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType,
string keywordName)
{
if (!element.TryGetProperty(keywordName, out var constraintElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.Integer &&
nodeType != YamlConfigSchemaPropertyType.Number)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'integer' and 'number' scalar types support numeric range constraints.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (constraintElement.ValueKind != JsonValueKind.Number ||
!constraintElement.TryGetDouble(out var constraintValue) ||
double.IsNaN(constraintValue) ||
double.IsInfinity(constraintValue))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a finite number.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintValue;
}
/// <summary>
/// 读取 <c>multipleOf</c> 约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <returns>步进约束;未声明时返回空。</returns>
private static double? TryParseMultipleOfConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
var multipleOf =
TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf");
if (!multipleOf.HasValue)
{
return null;
}
if (multipleOf.Value <= 0d)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'multipleOf' as a positive finite number.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return multipleOf;
}
/// <summary>
/// 读取字符串长度约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>长度约束;未声明时返回空。</returns>
private static int? TryParseLengthConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType,
string keywordName)
{
if (!element.TryGetProperty(keywordName, out var constraintElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'string' scalar types support length constraints.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (constraintElement.ValueKind != JsonValueKind.Number ||
!constraintElement.TryGetInt32(out var constraintValue) ||
constraintValue < 0)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintValue;
}
/// <summary>
/// 读取字符串正则约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <returns>正则模式;未声明时返回空。</returns>
private static string? TryParsePatternConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
if (!element.TryGetProperty("pattern", out var patternElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses 'pattern', but only 'string' scalar types support regular-expression constraints.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (patternElement.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'pattern' as a string.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var pattern = patternElement.GetString() ?? string.Empty;
try
{
_ = new Regex(pattern, SupportedPatternRegexOptions, SupportedFormatRegexTimeout);
}
catch (ArgumentException exception)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares an invalid 'pattern' regular expression.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
rawValue: pattern,
innerException: exception);
}
return pattern;
}
/// <summary>
/// 读取字符串 format 约束。
/// 运行时只接受当前三端共享并已验证收益的稳定子集,避免把开放格式名误当成“全部支持”。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="nodeType">字段类型。</param>
/// <returns>归一化后的 format 约束;未声明时返回空。</returns>
private static YamlConfigStringFormatConstraint? TryParseFormatConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
if (!element.TryGetProperty("format", out var formatElement))
{
return null;
}
if (nodeType != YamlConfigSchemaPropertyType.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses 'format', but only 'string' scalar types support string formats.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
if (formatElement.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'format' as a string.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var formatName = formatElement.GetString() ?? string.Empty;
if (TryMapSupportedStringFormat(formatName, out var kind))
{
return new YamlConfigStringFormatConstraint(formatName, kind);
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported string format '{formatName}'. Supported formats are {SupportedStringFormatNames}.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
rawValue: formatName);
}
/// <summary>
/// 将 schema 原文中的 format 名称映射为运行时共享枚举。
/// 映射阶段统一使用大小写敏感比较,避免同一 schema 在不同环境下被“宽松容错”解释成不同语义。
/// </summary>
/// <param name="formatName">schema 原始 format 名称。</param>
/// <param name="kind">解析成功时输出归一化枚举。</param>
/// <returns>当前格式是否属于共享支持子集。</returns>
private static bool TryMapSupportedStringFormat(
string formatName,
out YamlConfigStringFormatKind kind)
{
switch (formatName)
{
case "date":
kind = YamlConfigStringFormatKind.Date;
return true;
case "date-time":
kind = YamlConfigStringFormatKind.DateTime;
return true;
case "duration":
kind = YamlConfigStringFormatKind.Duration;
return true;
case "email":
kind = YamlConfigStringFormatKind.Email;
return true;
case "time":
kind = YamlConfigStringFormatKind.Time;
return true;
case "uri":
kind = YamlConfigStringFormatKind.Uri;
return true;
case "uuid":
kind = YamlConfigStringFormatKind.Uuid;
return true;
default:
kind = default;
return false;
}
}
/// <summary>
/// 读取数组元素数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <param name="keywordName">关键字名称。</param>
/// <returns>数组元素数量约束;未声明时返回空。</returns>
private static int? TryParseArrayLengthConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element,
string keywordName)
{
if (!element.TryGetProperty(keywordName, out var constraintElement))
{
return null;
}
if (constraintElement.ValueKind != JsonValueKind.Number ||
!constraintElement.TryGetInt32(out var constraintValue) ||
constraintValue < 0)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintValue;
}
/// <summary>
/// 读取数组去重约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>是否启用 <c>uniqueItems</c>。</returns>
private static bool TryParseUniqueItemsConstraint(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("uniqueItems", out var constraintElement))
{
return false;
}
if (constraintElement.ValueKind != JsonValueKind.True &&
constraintElement.ValueKind != JsonValueKind.False)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'uniqueItems' as a boolean.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return constraintElement.GetBoolean();
}
/// <summary>
/// 校验数值上下界组合不会形成空区间。
/// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="minimum">闭区间最小值。</param>
/// <param name="maximum">闭区间最大值。</param>
/// <param name="exclusiveMinimum">开区间最小值。</param>
/// <param name="exclusiveMaximum">开区间最大值。</param>
private static void ValidateNumericConstraintRange(
string tableName,
string schemaPath,
string propertyPath,
double? minimum,
double? maximum,
double? exclusiveMinimum,
double? exclusiveMaximum)
{
var hasLowerBound = false;
var lowerBound = double.MinValue;
var isLowerBoundExclusive = false;
if (minimum.HasValue)
{
hasLowerBound = true;
lowerBound = minimum.Value;
}
if (exclusiveMinimum.HasValue &&
(!hasLowerBound ||
exclusiveMinimum.Value > lowerBound ||
(exclusiveMinimum.Value.Equals(lowerBound) && !isLowerBoundExclusive)))
{
hasLowerBound = true;
lowerBound = exclusiveMinimum.Value;
isLowerBoundExclusive = true;
}
var hasUpperBound = false;
var upperBound = double.MaxValue;
var isUpperBoundExclusive = false;
if (maximum.HasValue)
{
hasUpperBound = true;
upperBound = maximum.Value;
}
if (exclusiveMaximum.HasValue &&
(!hasUpperBound ||
exclusiveMaximum.Value < upperBound ||
(exclusiveMaximum.Value.Equals(upperBound) && !isUpperBoundExclusive)))
{
hasUpperBound = true;
upperBound = exclusiveMaximum.Value;
isUpperBoundExclusive = true;
}
if (!hasLowerBound || !hasUpperBound)
{
return;
}
if (lowerBound > upperBound ||
(lowerBound.Equals(upperBound) && (isLowerBoundExclusive || isUpperBoundExclusive)))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares numeric constraints that do not leave any valid value range.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
}
/// <summary>
/// 校验标量值是否满足范围与长度约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
private static void ValidateScalarConstraints(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
string normalizedValue,
YamlConfigSchemaNode schemaNode)
{
var constraints = schemaNode.Constraints;
if (constraints is null)
{
return;
}
switch (schemaNode.NodeType)
{
case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number:
ValidateNumericScalarConstraints(
tableName,
yamlPath,
displayPath,
rawValue,
normalizedValue,
schemaNode,
constraints.NumericConstraints);
return;
case YamlConfigSchemaPropertyType.String:
ValidateStringScalarConstraints(
tableName,
yamlPath,
displayPath,
rawValue,
schemaNode,
constraints.StringConstraints);
return;
default:
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: schemaNode.NodeType.ToString());
}
}
/// <summary>
/// 校验节点值是否命中声明的 <c>enum</c> 集合。
/// 该实现与 <c>const</c> 共享同一套可比较键,保证对象字段顺序、数组顺序和数值归一化语义稳定一致。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径;根节点时为空。</param>
/// <param name="node">当前 YAML 节点。</param>
/// <param name="schemaNode">对应的 schema 节点。</param>
private static void ValidateAllowedValues(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
var allowedValues = schemaNode.AllowedValues;
if (allowedValues is null || allowedValues.Count == 0)
{
return;
}
var comparableValue = BuildComparableNodeValue(node, schemaNode);
if (allowedValues.Any(allowedValue =>
string.Equals(allowedValue.ComparableValue, comparableValue, StringComparison.Ordinal)))
{
return;
}
var subject = string.IsNullOrWhiteSpace(displayPath)
? "Root object"
: $"Property '{displayPath}'";
var displayValues = string.Join(", ", allowedValues.Select(static allowedValue => allowedValue.DisplayValue));
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.EnumValueNotAllowed,
tableName,
$"{subject} in config file '{yamlPath}' must be one of [{displayValues}].",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode),
detail: $"Allowed values: {displayValues}.");
}
/// <summary>
/// 校验节点值是否满足 <c>const</c> 约束。
/// 该检查复用与 <c>uniqueItems</c> 相同的稳定比较键,保证对象字段顺序、数字字面量和布尔大小写不会造成伪差异。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径;根节点时为空。</param>
/// <param name="node">当前 YAML 节点。</param>
/// <param name="schemaNode">对应的 schema 节点。</param>
private static void ValidateConstantValue(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
var constantValue = schemaNode.ConstantValue;
if (constantValue is null)
{
return;
}
var comparableValue = BuildComparableNodeValue(node, schemaNode);
if (string.Equals(comparableValue, constantValue.ComparableValue, StringComparison.Ordinal))
{
return;
}
var subject = string.IsNullOrWhiteSpace(displayPath)
? "Root object"
: $"Property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must match constant value {constantValue.DisplayValue}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode),
detail: $"Required constant value: {constantValue.DisplayValue}.");
}
/// <summary>
/// 根据已读取的数值关键字创建数值约束对象。
/// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。
/// </summary>
/// <param name="minimum">最小值约束。</param>
/// <param name="maximum">最大值约束。</param>
/// <param name="exclusiveMinimum">开区间最小值约束。</param>
/// <param name="exclusiveMaximum">开区间最大值约束。</param>
/// <param name="multipleOf">数值步进约束。</param>
/// <returns>数值约束对象;未声明任何数值约束时返回空。</returns>
private static YamlConfigNumericConstraints? CreateNumericScalarConstraints(
double? minimum,
double? maximum,
double? exclusiveMinimum,
double? exclusiveMaximum,
double? multipleOf)
{
return !minimum.HasValue &&
!maximum.HasValue &&
!exclusiveMinimum.HasValue &&
!exclusiveMaximum.HasValue &&
!multipleOf.HasValue
? null
: new YamlConfigNumericConstraints(
minimum,
maximum,
exclusiveMinimum,
exclusiveMaximum,
multipleOf);
}
/// <summary>
/// 根据已读取的字符串关键字创建字符串约束对象。
/// 正则会在 schema 解析阶段预编译,字符串 format 也会先归一化成共享枚举,
/// 避免每次校验都重新解释 schema 原文。
/// </summary>
/// <param name="minLength">最小长度约束。</param>
/// <param name="maxLength">最大长度约束。</param>
/// <param name="pattern">正则模式约束。</param>
/// <param name="formatConstraint">字符串 format 约束。</param>
/// <returns>字符串约束对象;未声明任何字符串约束时返回空。</returns>
private static YamlConfigStringConstraints? CreateStringScalarConstraints(
int? minLength,
int? maxLength,
string? pattern,
YamlConfigStringFormatConstraint? formatConstraint)
{
return !minLength.HasValue &&
!maxLength.HasValue &&
pattern is null &&
formatConstraint is null
? null
: new YamlConfigStringConstraints(
minLength,
maxLength,
pattern,
pattern is null
? null
: new Regex(
pattern,
SupportedPatternRegexOptions,
SupportedFormatRegexTimeout),
formatConstraint);
}
/// <summary>
/// 校验数值标量的区间与步进约束。
/// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="constraints">数值约束对象。</param>
private static void ValidateNumericScalarConstraints(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
string normalizedValue,
YamlConfigSchemaNode schemaNode,
YamlConfigNumericConstraints? constraints)
{
if (constraints is null)
{
return;
}
var numericValue = ParseComparableNumericValue(
tableName,
yamlPath,
displayPath,
rawValue,
normalizedValue,
schemaNode);
ValidateNumericLowerBounds(
tableName,
yamlPath,
displayPath,
rawValue,
schemaNode,
constraints,
numericValue);
ValidateNumericUpperBounds(
tableName,
yamlPath,
displayPath,
rawValue,
schemaNode,
constraints,
numericValue);
ValidateNumericMultipleOfConstraint(
tableName,
yamlPath,
displayPath,
rawValue,
normalizedValue,
schemaNode,
constraints,
numericValue);
}
/// <summary>
/// 校验数值的最小值与开区间下界。
/// </summary>
private static void ValidateNumericLowerBounds(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
YamlConfigSchemaNode schemaNode,
YamlConfigNumericConstraints constraints,
double numericValue)
{
if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
}
/// <summary>
/// 校验数值的最大值与开区间上界。
/// </summary>
private static void ValidateNumericUpperBounds(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
YamlConfigSchemaNode schemaNode,
YamlConfigNumericConstraints constraints,
double numericValue)
{
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
}
/// <summary>
/// 校验数值是否满足 multipleOf 步进约束。
/// </summary>
private static void ValidateNumericMultipleOfConstraint(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
string normalizedValue,
YamlConfigSchemaNode schemaNode,
YamlConfigNumericConstraints constraints,
double numericValue)
{
if (!constraints.MultipleOf.HasValue ||
IsMultipleOf(normalizedValue, numericValue, constraints.MultipleOf.Value))
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail:
$"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
}
/// <summary>
/// 将归一化后的数值文本还原为双精度值,用于统一后续区间比较。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="normalizedValue">归一化后的比较值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <returns>可比较的双精度值。</returns>
private static double ParseComparableNumericValue(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
string normalizedValue,
YamlConfigSchemaNode schemaNode)
{
if (double.TryParse(
normalizedValue,
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var numericValue))
{
return numericValue;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue);
}
/// <summary>
/// 校验字符串标量的长度、模式与 format 约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="rawValue">原始 YAML 标量值。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="constraints">字符串约束对象。</param>
private static void ValidateStringScalarConstraints(
string tableName,
string yamlPath,
string displayPath,
string rawValue,
YamlConfigSchemaNode schemaNode,
YamlConfigStringConstraints? constraints)
{
if (constraints is null)
{
return;
}
var stringLength = rawValue.Length;
if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Minimum length: {constraints.MinLength.Value}.");
}
if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Maximum length: {constraints.MaxLength.Value}.");
}
if (constraints.PatternRegex is not null &&
!constraints.PatternRegex.IsMatch(rawValue))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Expected pattern: {constraints.Pattern}.");
}
if (constraints.FormatConstraint is not null &&
!MatchesSupportedStringFormat(rawValue, constraints.FormatConstraint.Kind))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must satisfy string format '{constraints.FormatConstraint.SchemaName}', but the current YAML scalar value is '{rawValue}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Expected string format: {constraints.FormatConstraint.SchemaName}.");
}
}
/// <summary>
/// 判断一个字符串是否满足当前共享支持的 format 子集。
/// 这里只接受 Runtime / Generator / Tooling 三端都能稳定解释的格式,
/// 避免把 JSON Schema 的开放格式名直接当成“全部支持”。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <param name="formatKind">共享 format 枚举。</param>
/// <returns>当前字符串是否满足指定 format。</returns>
private static bool MatchesSupportedStringFormat(
string value,
YamlConfigStringFormatKind formatKind)
{
ArgumentNullException.ThrowIfNull(value);
return formatKind switch
{
YamlConfigStringFormatKind.Date => MatchesSupportedDateFormat(value),
YamlConfigStringFormatKind.DateTime => MatchesSupportedDateTimeFormat(value),
YamlConfigStringFormatKind.Duration => MatchesSupportedDurationFormat(value),
YamlConfigStringFormatKind.Email => SupportedEmailFormatRegex.IsMatch(value),
YamlConfigStringFormatKind.Time => MatchesSupportedTimeFormat(value),
YamlConfigStringFormatKind.Uri => MatchesSupportedUriFormat(value),
YamlConfigStringFormatKind.Uuid => Guid.TryParseExact(value, "D", out _),
_ => false
};
}
/// <summary>
/// 判断字符串是否满足共享支持的 <c>date</c> 格式。
/// 这里固定采用 <c>yyyy-MM-dd</c>,避免文化区域或宽松解析导致工具与运行时漂移。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <returns>当前值是否是合法日期。</returns>
private static bool MatchesSupportedDateFormat(string value)
{
return SupportedDateFormatRegex.IsMatch(value) &&
DateOnly.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _);
}
/// <summary>
/// 判断字符串是否满足共享支持的 <c>date-time</c> 格式。
/// 该格式固定要求显式时区偏移,避免消费者误把本地时区文本当成跨进程稳定配置。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <returns>当前值是否是合法时间戳。</returns>
private static bool MatchesSupportedDateTimeFormat(string value)
{
if (!SupportedDateTimeFormatRegex.IsMatch(value))
{
return false;
}
return DateTimeOffset.TryParseExact(
value,
["yyyy'-'MM'-'dd'T'HH':'mm':'ssK", "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"],
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _);
}
/// <summary>
/// 判断字符串是否满足共享支持的 <c>duration</c> 格式。
/// 当前共享子集只接受 day-time duration可声明 <c>D/H/M/S</c>,秒允许小数,
/// 但拒绝 <c>Y</c> / <c>M(月)</c> / <c>W</c> 等依赖日历语义的部分,
/// 避免不同宿主对“一个月/一年到底多长”出现解释漂移。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <returns>当前值是否是合法持续时间文本。</returns>
private static bool MatchesSupportedDurationFormat(string value)
{
var match = SupportedDurationFormatRegex.Match(value);
if (!match.Success)
{
return false;
}
var hasDayComponent = match.Groups["days"].Success;
var hasHourComponent = match.Groups["hours"].Success;
var hasMinuteComponent = match.Groups["minutes"].Success;
var hasSecondComponent = match.Groups["seconds"].Success;
var hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent;
if (!hasAnyComponent)
{
return false;
}
var hasTimeSection = value.Contains('T', StringComparison.Ordinal);
if (hasTimeSection &&
!hasHourComponent &&
!hasMinuteComponent &&
!hasSecondComponent)
{
return false;
}
return true;
}
/// <summary>
/// 判断字符串是否满足共享支持的 <c>time</c> 格式。
/// 该格式固定要求显式时区偏移,并只接受 24 小时制的合法时分秒文本,
/// 避免不同宿主对“time-only + offset”解析默认日期或时区时产生漂移。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <returns>当前值是否是合法时间文本。</returns>
private static bool MatchesSupportedTimeFormat(string value)
{
var match = SupportedTimeFormatRegex.Match(value);
if (!match.Success)
{
return false;
}
var hour = int.Parse(match.Groups["hour"].Value, CultureInfo.InvariantCulture);
var minute = int.Parse(match.Groups["minute"].Value, CultureInfo.InvariantCulture);
var second = int.Parse(match.Groups["second"].Value, CultureInfo.InvariantCulture);
if (hour > 23 || minute > 59 || second > 59)
{
return false;
}
var offset = match.Groups["offset"].Value;
if (string.Equals(offset, "Z", StringComparison.Ordinal))
{
return true;
}
var offsetHour = int.Parse(offset.AsSpan(1, 2), CultureInfo.InvariantCulture);
var offsetMinute = int.Parse(offset.AsSpan(4, 2), CultureInfo.InvariantCulture);
return offsetHour <= 23 && offsetMinute <= 59;
}
/// <summary>
/// 判断字符串是否满足共享支持的 <c>uri</c> 格式。
/// 这里要求输入显式包含 scheme避免把普通路径意外解释成平台相关的绝对 URI。
/// </summary>
/// <param name="value">待校验的字符串值。</param>
/// <returns>当前值是否是合法绝对 URI。</returns>
private static bool MatchesSupportedUriFormat(string value)
{
return SupportedUriSchemeRegex.IsMatch(value) &&
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
uri.IsAbsoluteUri;
}
/// <summary>
/// 校验数组值是否满足元素数量约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="itemCount">当前数组元素数量。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
private static void ValidateArrayConstraints(
string tableName,
string yamlPath,
string displayPath,
int itemCount,
YamlConfigSchemaNode schemaNode)
{
var constraints = schemaNode.ArrayConstraints;
if (constraints is null)
{
return;
}
if (constraints.MinItems.HasValue && itemCount < constraints.MinItems.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must contain at least {constraints.MinItems.Value} items, but the current YAML sequence contains {itemCount}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: itemCount.ToString(CultureInfo.InvariantCulture),
detail: $"Minimum item count: {constraints.MinItems.Value}.");
}
if (constraints.MaxItems.HasValue && itemCount > constraints.MaxItems.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must contain at most {constraints.MaxItems.Value} items, but the current YAML sequence contains {itemCount}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: itemCount.ToString(CultureInfo.InvariantCulture),
detail: $"Maximum item count: {constraints.MaxItems.Value}.");
}
}
/// <summary>
/// 校验数组是否满足去重约束。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
private static void ValidateArrayUniqueItemsConstraint(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
YamlConfigSchemaNode schemaNode)
{
var constraints = schemaNode.ArrayConstraints;
if (constraints is null ||
!constraints.UniqueItems ||
schemaNode.ItemNode is null)
{
return;
}
// The canonical item key uses schema-aware normalization so object key order,
// scalar quoting, and numeric formatting do not accidentally bypass uniqueItems.
Dictionary<string, int> seenItems = new(StringComparer.Ordinal);
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{
var itemNode = sequenceNode.Children[itemIndex];
var comparableValue = BuildComparableNodeValue(itemNode, schemaNode.ItemNode);
if (seenItems.TryGetValue(comparableValue, out var existingIndex))
{
var itemPath = $"{displayPath}[{itemIndex}]";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' requires unique array items, but item '{itemPath}' duplicates '{displayPath}[{existingIndex}]'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: itemPath,
rawValue: DescribeYamlNodeForDiagnostics(itemNode, schemaNode.ItemNode),
detail: "The schema declares uniqueItems = true.");
}
seenItems.Add(comparableValue, itemIndex);
}
}
/// <summary>
/// 校验数组是否满足 <c>contains</c> 声明的匹配数量边界。
/// 该实现会对每个数组项复用同一套递归校验逻辑做“非抛出式匹配”,避免 contains 与主校验链各自维护不同的 schema 解释规则。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <param name="references">匹配成功的 <c>contains</c> 子树所声明的跨表引用收集器。</param>
private static void ValidateArrayContainsConstraints(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references)
{
var containsConstraints = schemaNode.ArrayConstraints?.ContainsConstraints;
if (containsConstraints is null)
{
return;
}
var matchingCount = CountMatchingContainsItems(
tableName,
yamlPath,
displayPath,
sequenceNode,
containsConstraints.ContainsNode,
references);
var rawValue = matchingCount.ToString(CultureInfo.InvariantCulture);
var requiredMinContains = containsConstraints.MinContains ?? 1;
if (matchingCount < requiredMinContains)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must contain at least {requiredMinContains} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Minimum matching contains count: {requiredMinContains}.");
}
if (containsConstraints.MaxContains.HasValue &&
matchingCount > containsConstraints.MaxContains.Value)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must contain at most {containsConstraints.MaxContains.Value} items matching the 'contains' schema, but the current YAML sequence contains {matchingCount}.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
detail: $"Maximum matching contains count: {containsConstraints.MaxContains.Value}.");
}
}
/// <summary>
/// 统计当前数组中有多少元素满足 <c>contains</c> 子 schema。
/// 非预期内部错误会继续抛出,只有正常的 schema 不匹配才会被当成“当前元素不计数”。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">数组字段路径。</param>
/// <param name="sequenceNode">实际数组节点。</param>
/// <param name="containsNode">contains 子 schema。</param>
/// <param name="references">匹配成功元素的可选跨表引用收集器。</param>
/// <returns>匹配 <c>contains</c> 子 schema 的元素数量。</returns>
private static int CountMatchingContainsItems(
string tableName,
string yamlPath,
string displayPath,
YamlSequenceNode sequenceNode,
YamlConfigSchemaNode containsNode,
ICollection<YamlConfigReferenceUsage>? references)
{
var matchingCount = 0;
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{
if (TryMatchSchemaNode(
tableName,
yamlPath,
$"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex],
containsNode,
references,
allowUnknownObjectProperties: true))
{
matchingCount++;
}
}
return matchingCount;
}
/// <summary>
/// 判断当前 YAML 节点是否满足给定 schema 子树。
/// contains / not 都通过该路径复用主校验逻辑,因此普通约束失败会返回 <see langword="false" />
/// 但内部意外状态仍会继续抛出。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前节点路径。</param>
/// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">要试匹配的 schema 子树。</param>
/// <param name="references">当前节点匹配成功后要写回的可选跨表引用收集器。</param>
/// <param name="allowUnknownObjectProperties">对象试匹配时是否允许额外字段。</param>
/// <returns>当前节点是否匹配指定 schema 子树。</returns>
private static bool TryMatchSchemaNode(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode,
ICollection<YamlConfigReferenceUsage>? references,
bool allowUnknownObjectProperties)
{
// 约束试匹配不能把失败路径的引用泄漏给外层,但匹配成功的分支仍需要把引用写回,
// 这样 contains / not 等内联 schema 才能与主校验链复用同一套递归解释规则。
List<YamlConfigReferenceUsage>? matchedReferences = references is null ? null : new();
try
{
ValidateNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode,
matchedReferences,
allowUnknownObjectProperties);
if (references is not null &&
matchedReferences is not null)
{
AddUniqueReferenceUsages(references, matchedReferences);
}
return true;
}
catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind !=
ConfigLoadFailureKind.UnexpectedFailure)
{
return false;
}
}
/// <summary>
/// 将试匹配分支采集到的引用回写到外层集合,并按结构化标识去重。
/// </summary>
/// <param name="references">外层引用集合。</param>
/// <param name="matchedReferences">当前成功匹配分支采集到的引用。</param>
private static void AddUniqueReferenceUsages(
ICollection<YamlConfigReferenceUsage> references,
IEnumerable<YamlConfigReferenceUsage> matchedReferences)
{
foreach (var referenceUsage in matchedReferences)
{
if (!ContainsReferenceUsage(references, referenceUsage))
{
references.Add(referenceUsage);
}
}
}
/// <summary>
/// 判断外层引用集合中是否已经存在同一条引用使用记录。
/// </summary>
/// <param name="references">要检查的引用集合。</param>
/// <param name="candidate">当前待合并的引用记录。</param>
/// <returns>当集合中已存在语义相同的记录时返回 <see langword="true" />。</returns>
private static bool ContainsReferenceUsage(
IEnumerable<YamlConfigReferenceUsage> references,
YamlConfigReferenceUsage candidate)
{
foreach (var referenceUsage in references)
{
if (string.Equals(referenceUsage.YamlPath, candidate.YamlPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.SchemaPath, candidate.SchemaPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.PropertyPath, candidate.PropertyPath, StringComparison.Ordinal) &&
string.Equals(referenceUsage.RawValue, candidate.RawValue, StringComparison.Ordinal) &&
string.Equals(referenceUsage.ReferencedTableName, candidate.ReferencedTableName, StringComparison.Ordinal) &&
referenceUsage.ValueType == candidate.ValueType)
{
return true;
}
}
return false;
}
/// <summary>
/// 校验节点是否命中了 <c>not</c> 声明的禁用 schema。
/// 与 contains 不同not 会沿用主校验链的严格对象语义,避免把“声明属性子集”误当成完整命中。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前字段路径。</param>
/// <param name="node">当前 YAML 节点。</param>
/// <param name="schemaNode">当前 schema 节点。</param>
private static void ValidateNegatedSchemaConstraint(
string tableName,
string yamlPath,
string displayPath,
YamlNode node,
YamlConfigSchemaNode schemaNode)
{
if (schemaNode.NegatedSchemaNode is null ||
!TryMatchSchemaNode(
tableName,
yamlPath,
displayPath,
node,
schemaNode.NegatedSchemaNode,
references: null,
allowUnknownObjectProperties: false))
{
return;
}
var subject = string.IsNullOrWhiteSpace(displayPath)
? "Root object"
: $"Property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.ConstraintViolation,
tableName,
$"{subject} in config file '{yamlPath}' must not match the 'not' schema.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: DescribeYamlNodeForDiagnostics(node, schemaNode.NegatedSchemaNode),
detail: "The current YAML value matches the forbidden 'not' schema.");
}
/// <summary>
/// 将一个已通过结构校验的 YAML 节点归一化为可比较字符串。
/// 该键同时服务于 <c>uniqueItems</c> 与 <c>const</c>
/// 因此要忽略对象字段顺序和字符串引号形式。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">对应 schema 节点。</param>
/// <returns>可稳定比较的归一化键。</returns>
private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
return schemaNode.NodeType switch
{
YamlConfigSchemaPropertyType.Object => BuildComparableObjectValue(node, schemaNode),
YamlConfigSchemaPropertyType.Array => BuildComparableArrayValue(node, schemaNode),
YamlConfigSchemaPropertyType.Integer => BuildComparableScalarValue(node, schemaNode),
YamlConfigSchemaPropertyType.Number => BuildComparableScalarValue(node, schemaNode),
YamlConfigSchemaPropertyType.Boolean => BuildComparableScalarValue(node, schemaNode),
YamlConfigSchemaPropertyType.String => BuildComparableScalarValue(node, schemaNode),
_ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.")
};
}
/// <summary>
/// 构建对象节点的可比较键。
/// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 <c>uniqueItems</c> 的等价关系。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">对象 schema 节点。</param>
/// <returns>对象节点的稳定比较键。</returns>
private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
if (node is not YamlMappingNode mappingNode)
{
throw new InvalidOperationException("Validated object nodes must be YAML mappings.");
}
var properties = schemaNode.Properties
?? throw new InvalidOperationException(
"Validated object nodes must expose declared properties.");
var objectEntries = new List<KeyValuePair<string, string>>(mappingNode.Children.Count);
foreach (var entry in mappingNode.Children)
{
if (entry.Key is not YamlScalarNode keyNode ||
keyNode.Value is null ||
!properties.TryGetValue(keyNode.Value, out var propertySchema))
{
throw new InvalidOperationException("Validated object nodes must use declared scalar property names.");
}
objectEntries.Add(
new KeyValuePair<string, string>(
keyNode.Value,
BuildComparableNodeValue(entry.Value, propertySchema)));
}
objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
return string.Join(
"|",
objectEntries.Select(static entry =>
$"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}"));
}
/// <summary>
/// 构建数组节点的可比较键。
/// 数组仍保留元素顺序,因为 <c>uniqueItems</c> 只忽略对象字段顺序,不忽略数组顺序。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param>
/// <returns>数组节点的稳定比较键。</returns>
private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
if (node is not YamlSequenceNode sequenceNode ||
schemaNode.ItemNode is null)
{
throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema.");
}
return "[" +
string.Join(
",",
sequenceNode.Children.Select(item =>
{
var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
})) +
"]";
}
/// <summary>
/// 构建标量节点的可比较键。
/// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">标量 schema 节点。</param>
/// <returns>标量节点的稳定比较键。</returns>
private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode)
{
if (node is not YamlScalarNode scalarNode ||
scalarNode.Value is null)
{
throw new InvalidOperationException("Validated scalar nodes must be YAML scalars.");
}
var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
return
$"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
}
/// <summary>
/// 为唯一性诊断提取一个可读的节点摘要。
/// </summary>
/// <param name="node">YAML 节点。</param>
/// <param name="schemaNode">对应 schema 节点。</param>
/// <returns>诊断摘要。</returns>
private static string DescribeYamlNodeForDiagnostics(YamlNode node, YamlConfigSchemaNode schemaNode)
{
return schemaNode.NodeType switch
{
YamlConfigSchemaPropertyType.Object => "{...}",
YamlConfigSchemaPropertyType.Array => "[...]",
_ when node is YamlScalarNode scalarNode => scalarNode.Value ?? string.Empty,
_ => node.GetType().Name
};
}
/// <summary>
/// 判断数值是否满足 <c>multipleOf</c>。
/// 优先按十进制字面量做精确整倍数判断,
/// 以同时避免 0.1 / 0.01 这类十进制步进的伪失败和大数量级非整倍数的伪通过;
/// 只有当值超出精确十进制路径时才退回双精度容差比较。
/// </summary>
/// <param name="normalizedValue">用于数值比较的规范化 YAML 标量文本。</param>
/// <param name="value">当前值。</param>
/// <param name="divisor">步进约束。</param>
/// <returns>是否满足整倍数关系。</returns>
private static bool IsMultipleOf(string normalizedValue, double value, double divisor)
{
if (TryIsExactDecimalMultiple(normalizedValue, divisor, out var exactResult))
{
return exactResult;
}
var quotient = value / divisor;
var nearestInteger = Math.Round(quotient);
var tolerance = 1e-9 * Math.Max(1d, Math.Abs(quotient));
return Math.Abs(quotient - nearestInteger) <= tolerance;
}
/// <summary>
/// 尝试按十进制字面量精确判断 <c>multipleOf</c>。
/// 该路径直接对齐 YAML / JSON 中常见的有限十进制写法,
/// 避免双精度舍入把明显的非整倍数误判为合法。
/// </summary>
/// <param name="valueText">规范化后的 YAML 数值文本。</param>
/// <param name="divisor">Schema 声明的步进约束。</param>
/// <param name="isMultiple">精确路径下的判断结果。</param>
/// <returns>是否成功进入精确十进制判断路径。</returns>
private static bool TryIsExactDecimalMultiple(string valueText, double divisor, out bool isMultiple)
{
var divisorText = divisor.ToString("R", CultureInfo.InvariantCulture);
if (!TryParseExactDecimal(valueText, out var valueSignificand, out var valueScale) ||
!TryParseExactDecimal(divisorText, out var divisorSignificand, out var divisorScale) ||
divisorSignificand.IsZero)
{
isMultiple = false;
return false;
}
var commonScale = Math.Max(valueScale, divisorScale);
var scaledValue = ScaleDecimalSignificand(valueSignificand, valueScale, commonScale);
var scaledDivisor = ScaleDecimalSignificand(divisorSignificand, divisorScale, commonScale);
isMultiple = scaledValue % scaledDivisor == BigInteger.Zero;
return true;
}
/// <summary>
/// 将有限十进制或科学计数法文本拆成“整数有效数字 + 十进制位数”形式。
/// 这样可以把整倍数判断转成同一尺度下的整数取模,避免浮点误差参与计算。
/// </summary>
/// <param name="text">待解析的数值文本。</param>
/// <param name="significand">去掉小数点后的有效数字。</param>
/// <param name="scale">十进制缩放位数;原值等于 <paramref name="significand" /> / 10^<paramref name="scale" />。</param>
/// <returns>是否成功解析为有限十进制数。</returns>
private static bool TryParseExactDecimal(string text, out BigInteger significand, out int scale)
{
var match = ExactDecimalPattern.Match(text);
if (!match.Success)
{
significand = BigInteger.Zero;
scale = 0;
return false;
}
var exponentGroup = match.Groups["exponent"].Value;
var exponent = 0;
if (!string.IsNullOrEmpty(exponentGroup) &&
!int.TryParse(exponentGroup, NumberStyles.Integer, CultureInfo.InvariantCulture, out exponent))
{
significand = BigInteger.Zero;
scale = 0;
return false;
}
var integerDigits = match.Groups["integer"].Value;
var fractionDigits = match.Groups["fraction"].Success
? match.Groups["fraction"].Value
: match.Groups["fractionOnly"].Value;
var digits = string.Concat(integerDigits, fractionDigits);
if (digits.Length == 0)
{
digits = "0";
}
digits = digits.TrimStart('0');
if (digits.Length == 0)
{
significand = BigInteger.Zero;
scale = 0;
return true;
}
scale = checked(fractionDigits.Length - exponent);
if (scale < 0)
{
digits = string.Concat(digits, new string('0', -scale));
scale = 0;
}
while (scale > 0 && digits[^1] == '0')
{
digits = digits[..^1];
scale--;
}
significand = BigInteger.Parse(digits, CultureInfo.InvariantCulture);
if (string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal))
{
significand = BigInteger.Negate(significand);
}
return true;
}
/// <summary>
/// 将十进制有效数字放大到目标尺度,便于在同一量纲下执行整数取模。
/// </summary>
/// <param name="significand">原始有效数字。</param>
/// <param name="currentScale">当前十进制位数。</param>
/// <param name="targetScale">目标十进制位数。</param>
/// <returns>放大到目标尺度后的有效数字。</returns>
private static BigInteger ScaleDecimalSignificand(BigInteger significand, int currentScale, int targetScale)
{
if (currentScale == targetScale)
{
return significand;
}
return significand * BigInteger.Pow(10, targetScale - currentScale);
}
/// <summary>
/// 解析跨表引用目标表名称。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param>
/// <returns>目标表名称;未声明时返回空。</returns>
private static string? TryGetReferenceTableName(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("x-gframework-ref-table", out var referenceTableElement))
{
return null;
}
if (referenceTableElement.ValueKind != JsonValueKind.String)
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
var referenceTableName = referenceTableElement.GetString();
if (string.IsNullOrWhiteSpace(referenceTableName))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
return referenceTableName;
}
/// <summary>
/// 验证哪些 schema 类型允许声明跨表引用。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="propertyType">字段类型。</param>
/// <param name="referenceTableName">目标表名称。</param>
private static void EnsureReferenceKeywordIsSupported(
string tableName,
string schemaPath,
string propertyPath,
YamlConfigSchemaPropertyType propertyType,
string? referenceTableName)
{
if (referenceTableName == null)
{
return;
}
if (propertyType == YamlConfigSchemaPropertyType.String ||
propertyType == YamlConfigSchemaPropertyType.Integer)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
referencedTableName: referenceTableName);
}
/// <summary>
/// 递归收集 schema 中声明的目标表名称。
/// </summary>
/// <param name="node">当前 schema 节点。</param>
/// <param name="referencedTableNames">输出集合。</param>
private static void CollectReferencedTableNames(
YamlConfigSchemaNode node,
ISet<string> referencedTableNames)
{
if (node.ReferenceTableName != null)
{
referencedTableNames.Add(node.ReferenceTableName);
}
if (node.Properties is not null)
{
foreach (var property in node.Properties.Values)
{
CollectReferencedTableNames(property, referencedTableNames);
}
}
if (node.ItemNode is not null)
{
CollectReferencedTableNames(node.ItemNode, referencedTableNames);
}
var containsNode = node.ArrayConstraints?.ContainsConstraints?.ContainsNode;
if (containsNode is not null)
{
CollectReferencedTableNames(containsNode, referencedTableNames);
}
var dependentSchemas = node.ObjectConstraints?.DependentSchemas;
if (dependentSchemas is not null)
{
foreach (var dependentSchemaNode in dependentSchemas.Values)
{
CollectReferencedTableNames(dependentSchemaNode, referencedTableNames);
}
}
var allOfSchemas = node.ObjectConstraints?.AllOfSchemas;
if (allOfSchemas is not null)
{
foreach (var allOfSchemaNode in allOfSchemas)
{
CollectReferencedTableNames(allOfSchemaNode, referencedTableNames);
}
}
var conditionalSchemas = node.ObjectConstraints?.ConditionalSchemas;
if (conditionalSchemas is not null)
{
CollectReferencedTableNames(conditionalSchemas.IfSchema, referencedTableNames);
if (conditionalSchemas.ThenSchema is not null)
{
CollectReferencedTableNames(conditionalSchemas.ThenSchema, referencedTableNames);
}
if (conditionalSchemas.ElseSchema is not null)
{
CollectReferencedTableNames(conditionalSchemas.ElseSchema, referencedTableNames);
}
}
}
/// <summary>
/// 将 schema 关键字中的标量值归一化到运行时比较字符串。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param>
/// <param name="expectedType">期望的标量类型。</param>
/// <param name="item">当前关键字值节点。</param>
/// <returns>归一化后的字符串值。</returns>
private static string NormalizeKeywordScalarValue(
string tableName,
string schemaPath,
string propertyPath,
string keywordName,
YamlConfigSchemaPropertyType expectedType,
JsonElement item)
{
try
{
return expectedType switch
{
YamlConfigSchemaPropertyType.String when item.ValueKind == JsonValueKind.String =>
item.GetString() ?? string.Empty,
YamlConfigSchemaPropertyType.Integer when item.ValueKind == JsonValueKind.Number =>
item.GetInt64().ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Number when item.ValueKind == JsonValueKind.Number =>
item.GetDouble().ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.True =>
bool.TrueString.ToLowerInvariant(),
YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.False =>
bool.FalseString.ToLowerInvariant(),
_ => throw new InvalidOperationException()
};
}
catch
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
}
/// <summary>
/// 将内部路径转换为适合放入诊断对象的可选字段路径。
/// </summary>
/// <param name="path">内部使用的属性路径。</param>
/// <returns>可用于诊断的路径;根节点时返回空。</returns>
private static string? GetDiagnosticPath(string path)
{
return string.IsNullOrWhiteSpace(path) || string.Equals(path, "<root>", StringComparison.Ordinal)
? null
: path;
}
/// <summary>
/// 将 YAML 标量值规范化成运行时比较格式。
/// </summary>
/// <param name="expectedType">期望的标量类型。</param>
/// <param name="value">原始字符串值。</param>
/// <returns>归一化后的字符串。</returns>
private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value)
{
return expectedType switch
{
YamlConfigSchemaPropertyType.String => value,
YamlConfigSchemaPropertyType.Integer when long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var integerValue) =>
integerValue.ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Number when double.TryParse(
value,
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var numberValue) =>
numberValue.ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Boolean when bool.TryParse(value, out var booleanValue) =>
booleanValue.ToString().ToLowerInvariant(),
YamlConfigSchemaPropertyType.Integer =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as integer."),
YamlConfigSchemaPropertyType.Number =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as number."),
YamlConfigSchemaPropertyType.Boolean =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as boolean."),
_ =>
throw new InvalidOperationException(
$"Schema node type '{expectedType}' cannot be normalized as a scalar value.")
};
}
/// <summary>
/// 获取 schema 类型的可读名称,用于错误信息。
/// </summary>
/// <param name="type">Schema 节点类型。</param>
/// <returns>可读类型名。</returns>
private static string GetTypeName(YamlConfigSchemaPropertyType type)
{
return type switch
{
YamlConfigSchemaPropertyType.Integer => "integer",
YamlConfigSchemaPropertyType.Number => "number",
YamlConfigSchemaPropertyType.Boolean => "boolean",
YamlConfigSchemaPropertyType.String => "string",
YamlConfigSchemaPropertyType.Array => "array",
YamlConfigSchemaPropertyType.Object => "object",
_ => type.ToString()
};
}
/// <summary>
/// 组合 schema 中的逻辑路径,便于诊断时指出深层字段。
/// </summary>
/// <param name="parentPath">父级路径。</param>
/// <param name="propertyName">当前属性名。</param>
/// <returns>组合后的路径。</returns>
private static string CombineSchemaPath(string parentPath, string propertyName)
{
return string.Equals(parentPath, "<root>", StringComparison.Ordinal)
? propertyName
: $"{parentPath}.{propertyName}";
}
/// <summary>
/// 组合 YAML 诊断展示路径。
/// </summary>
/// <param name="parentPath">父级路径。</param>
/// <param name="propertyName">当前属性名。</param>
/// <returns>组合后的路径。</returns>
private static string CombineDisplayPath(string parentPath, string propertyName)
{
return string.IsNullOrWhiteSpace(parentPath) ? propertyName : $"{parentPath}.{propertyName}";
}
/// <summary>
/// 返回当前节点上声明的“仅对象可用”关键字名称。
/// </summary>
/// <param name="element">Schema 节点。</param>
/// <returns>命中的关键字名称;未命中时返回空。</returns>
private static string? TryGetObjectOnlyKeywordName(JsonElement element)
{
if (element.TryGetProperty("allOf", out _))
{
return "allOf";
}
if (element.TryGetProperty("if", out _))
{
return "if";
}
if (element.TryGetProperty("then", out _))
{
return "then";
}
return element.TryGetProperty("else", out _)
? "else"
: null;
}
/// <summary>
/// 判断当前标量是否应按字符串处理。
/// 这里显式排除 YAML 的数字、布尔和 null 标签,避免未加引号的值被当成字符串混入运行时。
/// </summary>
/// <param name="tag">YAML 标量标签。</param>
/// <returns>是否为字符串标量。</returns>
private static bool IsStringScalar(string tag)
{
if (string.IsNullOrWhiteSpace(tag))
{
return true;
}
return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) &&
!string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal);
}
}