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 "); 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(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( 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 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(?.*?)\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 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 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 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 widths = ProjectionWidths(select.Select); if (widths.Count == 0 || widths.Any(width => width != expected)) throw new InvalidOperationException( "insert_select_arity_mismatch " + location); } } } private static IReadOnlyList ProjectionWidths( QueryExpression expression) { if (expression is QuerySpecification specification) { if (specification.SelectElements.Count == 0 || specification.SelectElements.Any(item => item is SelectStarExpression)) return Array.Empty(); 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(); } private sealed class InsertStatementCollector : TSqlFragmentVisitor { public List Statements { get; } = new(); public override void ExplicitVisit(InsertStatement node) { Statements.Add(node); } } private sealed class CreateProcedureCollector : TSqlFragmentVisitor { public List Statements { get; } = new(); public override void ExplicitVisit(CreateProcedureStatement node) { Statements.Add(node); } } private sealed class AlterProcedureCollector : TSqlFragmentVisitor { public List 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 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 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; } }