Files
2026-08-14 14:28:28 +08:00

636 lines
26 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
using Microsoft.SqlServer.TransactSql.ScriptDom;
internal static class Program
{
private const string ParserPackage =
"Microsoft.SqlServer.TransactSql.ScriptDom";
private const string ParserPackageVersion = "180.59.2";
private const string ParserDialect = "TSql100";
private const long MaximumScriptBytes = 2L * 1024L * 1024L;
private static readonly string[] ExpectedScripts =
{
"插件库/Lskj.AgentBridge/Deployment/SqlServer/001_agent_business_idempotency.sql",
"插件库/Lskj.AgentBridge/Deployment/SqlServer/002_workflow_adapter_contract.sql",
"插件库/Lskj.AgentBridge/Deployment/SqlServer/003_record_workflow_acceptance.sql",
"插件库/Lskj.AgentBridge/Deployment/SqlServer/004_dynamic_module_adapter_contract.sql",
"插件库/Lskj.AgentBridge/Deployment/SqlServer/005_dynamic_module_update_contract.sql",
"插件库/Lskj.AgentBridge/Deployment/SqlServer/006_workflow_readiness_v3.sql",
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/lserp-ai.workflow-read.compat100.draft.sql",
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/lserp-ai.workflow-write.leave.compat100.draft.sql",
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/lserp-ai.workflow-write.purchase.compat100.draft.sql"
};
private static int _passed;
private static int _failed;
private static int Main(string[] args)
{
if (args.Length != 1 || string.IsNullOrWhiteSpace(args[0]))
{
Console.Error.WriteLine("usage: Lskj.SqlContract.Tests <repo-root>");
return 2;
}
string repoRoot;
try
{
repoRoot = Path.GetFullPath(args[0]);
}
catch (Exception error)
{
Console.Error.WriteLine("invalid_repo_root: " + error.GetType().Name);
return 2;
}
Run("sql_contract_file_set_is_exact", () =>
VerifyExactScriptSet(repoRoot));
foreach (string relativePath in ExpectedScripts)
{
string testName = "compat100_parse_" +
Path.GetFileNameWithoutExtension(relativePath)
.Replace('.', '_')
.Replace('-', '_');
Run(testName, () => ParseCompatibility100File(
repoRoot,
relativePath));
}
Run("workflow_write_procedure_parameters_are_exact", () =>
VerifyWorkflowWriteProcedureParameters(repoRoot));
Run("workflow_readiness_v3_binds_signature_and_modify_date", () =>
VerifyWorkflowReadinessV3(repoRoot));
Run("purchase_requested_lines_projection_is_exact", () =>
VerifyPurchaseRequestedLinesProjection(repoRoot));
Run("explicit_insert_projection_arities_match", () =>
VerifyExplicitInsertProjectionArities(repoRoot));
Run("select_only_catalog_embedded_queries_are_tsql100_selects", () =>
VerifySelectOnlyCatalogQueries(repoRoot));
Run("tsql100_rejects_newer_create_or_alter_syntax",
RejectNewerSyntax);
Console.WriteLine(
$"parserPackage={ParserPackage} parserVersion={ParserPackageVersion} dialect={ParserDialect}");
Console.WriteLine($"passed={_passed} failed={_failed}");
return _failed == 0 ? 0 : 1;
}
private static void Run(string name, Action action)
{
try
{
action();
_passed += 1;
Console.WriteLine("PASS " + name);
}
catch (Exception error)
{
_failed += 1;
Console.WriteLine("FAIL " + name + " :: " + SafeMessage(error.Message));
}
}
private static void VerifyExactScriptSet(string repoRoot)
{
string deploymentRoot = Path.Combine(
repoRoot,
"插件库",
"Lskj.AgentBridge",
"Deployment");
string[] roots =
{
Path.Combine(deploymentRoot, "SqlServer"),
Path.Combine(deploymentRoot, "customer-profiles")
};
var actual = new HashSet<string>(StringComparer.Ordinal);
foreach (string root in roots)
{
if (!Directory.Exists(root))
throw new InvalidOperationException("sql_contract_directory_missing");
foreach (string path in Directory.EnumerateFiles(
root,
"*.sql",
SearchOption.TopDirectoryOnly))
{
actual.Add(NormalizeRelativePath(repoRoot, path));
}
}
var expected = new HashSet<string>(
ExpectedScripts,
StringComparer.Ordinal);
if (!actual.SetEquals(expected))
{
string missing = string.Join(",", expected.Except(actual).OrderBy(x => x));
string unexpected = string.Join(",", actual.Except(expected).OrderBy(x => x));
throw new InvalidOperationException(
$"sql_contract_file_set_mismatch missing=[{missing}] unexpected=[{unexpected}]");
}
}
private static void ParseCompatibility100File(
string repoRoot,
string relativePath)
{
_ = ParseCompatibility100Script(repoRoot, relativePath);
}
private static TSqlScript ParseCompatibility100Script(
string repoRoot,
string relativePath)
{
string path = Path.GetFullPath(Path.Combine(
repoRoot,
relativePath.Replace('/', Path.DirectorySeparatorChar)));
string rootPrefix = repoRoot.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!path.StartsWith(rootPrefix, StringComparison.Ordinal))
throw new InvalidOperationException("sql_contract_path_outside_repo");
var file = new FileInfo(path);
if (!file.Exists
|| file.Length <= 0
|| file.Length > MaximumScriptBytes
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException("sql_contract_file_invalid");
}
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read);
if (stream.Length != file.Length)
throw new InvalidOperationException("sql_contract_file_changed");
using var reader = new StreamReader(
stream,
new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true),
detectEncodingFromByteOrderMarks: true,
bufferSize: 4096,
leaveOpen: false);
var parser = new TSql100Parser(initialQuotedIdentifiers: true);
TSqlFragment fragment = parser.Parse(reader, out IList<ParseError> errors);
if (errors.Count != 0)
throw new InvalidOperationException(FormatErrors(errors));
if (fragment is not TSqlScript script || script.Batches.Count == 0)
throw new InvalidOperationException("sql_contract_ast_empty");
return script;
}
private static void VerifySelectOnlyCatalogQueries(string repoRoot)
{
string relativePath =
"插件库/Lskj.AgentBridge/Deployment/"
+ "Invoke-LserpSelectOnlyCatalogSnapshot.ps1";
string path = Path.GetFullPath(Path.Combine(
repoRoot,
relativePath.Replace('/', Path.DirectorySeparatorChar)));
string rootPrefix = repoRoot.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!path.StartsWith(rootPrefix, StringComparison.Ordinal))
throw new InvalidOperationException("catalog_tool_path_outside_repo");
var file = new FileInfo(path);
if (!file.Exists
|| file.Length <= 0
|| file.Length > MaximumScriptBytes
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException("catalog_tool_file_invalid");
}
string source;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
using (var reader = new StreamReader(
stream,
new UTF8Encoding(false, true),
detectEncodingFromByteOrderMarks: true))
{
source = reader.ReadToEnd();
}
foreach (string variable in new[] { "permissionQuery", "metadataQuery" })
{
MatchCollection matches = Regex.Matches(
source,
@"\$" + variable + @"\s*=\s*@'\r?\n(?<sql>.*?)\r?\n'@",
RegexOptions.Singleline | RegexOptions.CultureInvariant);
if (matches.Count != 1)
throw new InvalidOperationException(
"catalog_tool_query_literal_invalid:" + variable);
string sql = matches[0].Groups["sql"].Value;
var parser = new TSql100Parser(initialQuotedIdentifiers: true);
TSqlFragment fragment;
using (var verificationReader = new StringReader(sql))
{
fragment = parser.Parse(
verificationReader,
out IList<ParseError> errors);
if (errors.Count != 0)
throw new InvalidOperationException(FormatErrors(errors));
}
if (fragment is not TSqlScript script
|| script.Batches.Count == 0
|| script.Batches.Any(batch => batch.Statements.Count == 0)
|| script.Batches.SelectMany(batch => batch.Statements)
.Any(statement => statement is not SelectStatement))
{
throw new InvalidOperationException(
"catalog_tool_query_not_select_only:" + variable);
}
}
}
private static void VerifyWorkflowWriteProcedureParameters(
string repoRoot)
{
VerifyProcedureParameters(
repoRoot,
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/"
+ "lserp-ai.workflow-write.purchase.compat100.draft.sql",
"dbo.p_lserp_agent_workflow_write_purchase_compat100",
new[]
{
"@action:varchar(64):required",
"@module_code:nvarchar(64):required",
"@account_book:nvarchar(64):required",
"@subsystem_id:nvarchar(32):required",
"@user_id:nvarchar(64):required",
"@correlation_id:varchar(128):required",
"@idempotency_key:varchar(128):required",
"@input_fingerprint:char(64):required",
"@supplier_code:nvarchar(64):required",
"@currency_code:nvarchar(64):required",
"@invoice_number:nvarchar(128):required",
"@invoice_date:datetime:required",
"@total_without_tax:decimal(28,8):required",
"@tax_amount:decimal(28,8):required",
"@total_with_tax:decimal(28,8):required",
"@lines_xml:xml:required",
"@source_documents_xml:xml:required"
});
VerifyProcedureParameters(
repoRoot,
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/"
+ "lserp-ai.workflow-write.leave.compat100.draft.sql",
"dbo.p_lserp_agent_workflow_write_leave_compat100",
new[]
{
"@action:varchar(64):required",
"@module_code:nvarchar(64):required",
"@account_book:nvarchar(64):required",
"@subsystem_id:nvarchar(32):required",
"@user_id:nvarchar(64):required",
"@correlation_id:varchar(128):required",
"@idempotency_key:varchar(128):required",
"@input_fingerprint:char(64):required",
"@employee_id:nvarchar(64):optional",
"@leave_type_code:nvarchar(64):optional",
"@flow_type_code:nvarchar(64):optional",
"@start_local:datetime:optional",
"@end_local:datetime:optional",
"@requested_hours:decimal(18,6):optional",
"@reason:nvarchar(500):optional",
"@submit_after_save_intent:bit:optional",
"@record_id:nvarchar(128):optional"
});
}
private static void VerifyWorkflowReadinessV3(string repoRoot)
{
const string relativePath =
"插件库/Lskj.AgentBridge/Deployment/SqlServer/"
+ "006_workflow_readiness_v3.sql";
TSqlScript script = ParseCompatibility100Script(repoRoot, relativePath);
var collector = new AlterProcedureCollector();
script.Accept(collector);
if (collector.Statements.Count != 1)
throw new InvalidOperationException(
"workflow_readiness_v3_alter_not_unique");
AlterProcedureStatement statement = collector.Statements[0];
var generator = new Sql100ScriptGenerator();
generator.GenerateScript(statement.ProcedureReference, out string procedureName);
if (NormalizeSqlFragment(procedureName)
!= "dbo.p_lserp_agent_workflow_readiness_v3")
throw new InvalidOperationException(
"workflow_readiness_v3_name_changed");
string[] expectedParameters =
{
"@workflow:varchar(32)",
"@module_code:nvarchar(64)",
"@account_book:nvarchar(128)",
"@subsystem_id:nvarchar(128)"
};
string[] actualParameters = statement.Parameters.Select(parameter =>
{
generator.GenerateScript(parameter.DataType, out string dataType);
return parameter.VariableName.Value.ToLowerInvariant()
+ ":" + NormalizeSqlFragment(dataType);
}).ToArray();
if (!actualParameters.SequenceEqual(
expectedParameters,
StringComparer.Ordinal))
throw new InvalidOperationException(
"workflow_readiness_v3_parameters_changed");
generator.GenerateScript(statement, out string generated);
string compact = NormalizeSqlFragment(generated);
foreach (string required in new[]
{
"sys.parameters",
"contract_parameter.parameter_id",
"@actual_read_signature",
"@actual_write_signature",
"isnull(@actual_read_signature,n'')",
"isnull(@actual_write_signature,n'')",
"latin1_general_100_bin2",
"sys.procedures",
"modify_date",
"validated_at_utc>=@latest_contract_modified_utc",
"validated_at_utc<=dateadd(minute,5,sysutcdatetime())",
"dbo.p_lserp_agent_workflow_read",
"dbo.p_lserp_agent_workflow_read_compat100",
"dbo.p_lserp_agent_workflow_write",
"dbo.p_lserp_agent_workflow_write_purchase_compat100",
"dbo.p_lserp_agent_workflow_write_leave_compat100",
"dbo.p_agent_workflow_adapter_evidence_v2"
})
{
if (!compact.Contains(required, StringComparison.Ordinal))
throw new InvalidOperationException(
"workflow_readiness_v3_guard_missing " + required);
}
}
private static void VerifyProcedureParameters(
string repoRoot,
string relativePath,
string expectedProcedureName,
IReadOnlyList<string> expectedParameters)
{
TSqlScript script = ParseCompatibility100Script(
repoRoot,
relativePath);
var collector = new CreateProcedureCollector();
script.Accept(collector);
if (collector.Statements.Count != 1)
throw new InvalidOperationException(
"workflow_write_procedure_not_unique "
+ Path.GetFileName(relativePath));
CreateProcedureStatement statement = collector.Statements[0];
var generator = new Sql100ScriptGenerator();
generator.GenerateScript(
statement.ProcedureReference,
out string procedureName);
if (!string.Equals(
NormalizeSqlFragment(procedureName),
NormalizeSqlFragment(expectedProcedureName),
StringComparison.Ordinal))
throw new InvalidOperationException(
"workflow_write_procedure_name_changed "
+ Path.GetFileName(relativePath));
string[] actual = statement.Parameters.Select(parameter =>
{
generator.GenerateScript(parameter.DataType, out string dataType);
generator.GenerateScript(parameter, out string declaration);
string requirement = declaration.Contains('=', StringComparison.Ordinal)
? "optional"
: "required";
return parameter.VariableName.Value.ToLowerInvariant()
+ ":" + NormalizeSqlFragment(dataType)
+ ":" + requirement;
}).ToArray();
if (!actual.SequenceEqual(expectedParameters, StringComparer.Ordinal))
throw new InvalidOperationException(
"workflow_write_procedure_parameters_changed "
+ Path.GetFileName(relativePath)
+ " expected=[" + string.Join(",", expectedParameters) + "]"
+ " actual=[" + string.Join(",", actual) + "]");
}
private static string NormalizeSqlFragment(string value)
{
return Regex.Replace(
value ?? string.Empty,
@"[\s\[\]]+",
string.Empty,
RegexOptions.CultureInvariant)
.ToLowerInvariant();
}
private static void VerifyPurchaseRequestedLinesProjection(
string repoRoot)
{
const string relativePath =
"插件库/Lskj.AgentBridge/Deployment/customer-profiles/"
+ "lserp-ai.workflow-write.purchase.compat100.draft.sql";
TSqlScript script = ParseCompatibility100Script(
repoRoot,
relativePath);
var collector = new InsertStatementCollector();
script.Accept(collector);
List<InsertSpecification> matches = collector.Statements
.Select(item => item.InsertSpecification)
.Where(item => item.Target is VariableTableReference target
&& string.Equals(
target.Variable.Name,
"@requested_lines",
StringComparison.OrdinalIgnoreCase))
.ToList();
if (matches.Count != 1)
throw new InvalidOperationException(
"purchase_requested_lines_insert_not_unique");
InsertSpecification insert = matches[0];
string[] expectedColumns =
{
"line_id", "material_code", "unit_name", "quantity",
"unit_price", "tax_rate", "line_tax_amount", "line_amount",
"source_order_id", "source_order_number", "source_line_id",
"source_unit", "source_remaining_quantity",
"source_unit_price", "source_tax_rate", "source_exchange_rate"
};
string[] expectedAttributes =
{
"line_id", "material_code", "unit", "quantity", "unit_price",
"tax_rate", "tax_amount", "line_amount", "source_order_id",
"source_order_number", "source_line_id", "source_unit",
"source_remaining_quantity", "source_unit_price",
"source_tax_rate", "source_exchange_rate"
};
string[] actualColumns = insert.Columns
.Select(item => item.MultiPartIdentifier?.Identifiers
.LastOrDefault()?.Value ?? string.Empty)
.ToArray();
if (!actualColumns.SequenceEqual(
expectedColumns,
StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException(
"purchase_requested_lines_target_columns_changed");
if (insert.InsertSource is not SelectInsertSource source
|| source.Select is not QuerySpecification query
|| query.SelectElements.Count != expectedAttributes.Length
|| query.SelectElements.Any(item =>
item is not SelectScalarExpression))
throw new InvalidOperationException(
"purchase_requested_lines_select_arity_mismatch");
var generator = new Sql100ScriptGenerator();
for (int index = 0; index < expectedAttributes.Length; index++)
{
var scalar = (SelectScalarExpression)query.SelectElements[index];
generator.GenerateScript(scalar.Expression, out string text);
Match attribute = Regex.Match(
text,
@"\(@([a-z_]+)\)\[1\]",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (!attribute.Success
|| !string.Equals(
attribute.Groups[1].Value,
expectedAttributes[index],
StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"purchase_requested_lines_attribute_mapping_invalid_"
+ expectedColumns[index]);
}
}
private static void VerifyExplicitInsertProjectionArities(
string repoRoot)
{
var generator = new Sql100ScriptGenerator();
foreach (string relativePath in ExpectedScripts)
{
TSqlScript script = ParseCompatibility100Script(
repoRoot,
relativePath);
var collector = new InsertStatementCollector();
script.Accept(collector);
foreach (InsertSpecification insert in collector.Statements
.Select(item => item.InsertSpecification)
.Where(item => item.Columns.Count != 0))
{
int expected = insert.Columns.Count;
generator.GenerateScript(insert.Target, out string target);
string location = Path.GetFileName(relativePath) + ":"
+ insert.StartLine + ":" + SafeMessage(target);
if (insert.InsertSource is ValuesInsertSource values)
{
if (values.IsDefaultValues
|| values.RowValues.Count == 0
|| values.RowValues.Any(row =>
row.ColumnValues.Count != expected))
throw new InvalidOperationException(
"insert_values_arity_mismatch " + location);
continue;
}
if (insert.InsertSource is not SelectInsertSource select)
continue;
IReadOnlyList<int> widths = ProjectionWidths(select.Select);
if (widths.Count == 0 || widths.Any(width => width != expected))
throw new InvalidOperationException(
"insert_select_arity_mismatch " + location);
}
}
}
private static IReadOnlyList<int> ProjectionWidths(
QueryExpression expression)
{
if (expression is QuerySpecification specification)
{
if (specification.SelectElements.Count == 0
|| specification.SelectElements.Any(item =>
item is SelectStarExpression))
return Array.Empty<int>();
return new[] { specification.SelectElements.Count };
}
if (expression is BinaryQueryExpression binary)
return ProjectionWidths(binary.FirstQueryExpression)
.Concat(ProjectionWidths(binary.SecondQueryExpression))
.ToArray();
if (expression is QueryParenthesisExpression parenthesis)
return ProjectionWidths(parenthesis.QueryExpression);
return Array.Empty<int>();
}
private sealed class InsertStatementCollector : TSqlFragmentVisitor
{
public List<InsertStatement> Statements { get; } = new();
public override void ExplicitVisit(InsertStatement node)
{
Statements.Add(node);
}
}
private sealed class CreateProcedureCollector : TSqlFragmentVisitor
{
public List<CreateProcedureStatement> Statements { get; } = new();
public override void ExplicitVisit(CreateProcedureStatement node)
{
Statements.Add(node);
}
}
private sealed class AlterProcedureCollector : TSqlFragmentVisitor
{
public List<AlterProcedureStatement> Statements { get; } = new();
public override void ExplicitVisit(AlterProcedureStatement node)
{
Statements.Add(node);
}
}
private static void RejectNewerSyntax()
{
const string sql =
"CREATE OR ALTER PROCEDURE dbo.p_lserp_newer_syntax AS SELECT 1;";
var parser = new TSql100Parser(initialQuotedIdentifiers: true);
using var reader = new StringReader(sql);
_ = parser.Parse(reader, out IList<ParseError> errors);
if (errors.Count == 0)
throw new InvalidOperationException("tsql100_parser_accepted_newer_syntax");
}
private static string NormalizeRelativePath(string root, string path)
{
string value = Path.GetRelativePath(root, path)
.Replace(Path.DirectorySeparatorChar, '/');
if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar)
value = value.Replace(Path.AltDirectorySeparatorChar, '/');
return value;
}
private static string FormatErrors(IEnumerable<ParseError> errors)
{
return "tsql100_parse_error " + string.Join(
" | ",
errors.Take(10).Select(error =>
$"SQL{error.Number}@{error.Line}:{error.Column} {SafeMessage(error.Message)}"));
}
private static string SafeMessage(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "unknown_error";
string normalized = new string(value
.Where(character => !char.IsControl(character))
.Take(1000)
.ToArray());
return normalized.Length == 0 ? "unknown_error" : normalized;
}
}