|
5 | 5 | //------------------------------------------------------------------------------ |
6 | 6 |
|
7 | 7 | using System; |
| 8 | +using System.Collections.Generic; |
8 | 9 | using System.IO; |
9 | 10 | using Microsoft.SqlServer.TransactSql.ScriptDom; |
10 | 11 | using Microsoft.VisualStudio.TestTools.UnitTesting; |
@@ -335,6 +336,205 @@ void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorO |
335 | 336 | Assert.AreEqual(sqlText, generatedSqlText); |
336 | 337 | } |
337 | 338 |
|
| 339 | + #region Generator Whitespace Regression Tests |
| 340 | + |
| 341 | + [TestMethod] |
| 342 | + [Priority(0)] |
| 343 | + [SqlStudioTestCategory(Category.UnitTest)] |
| 344 | + public void TestAlterTableAddColumnThenIndex_EmitsSeparatorBeforeIndex() |
| 345 | + { |
| 346 | + // Verbatim sample from docs/relational-databases/in-memory-oltp/ |
| 347 | + // altering-memory-optimized-tables.md (line 119). Generator was |
| 348 | + // omitting the separator between the trailing column constraint |
| 349 | + // ('NOT NULL') and the inline INDEX, producing 'NOT NULLINDEX |
| 350 | + // ix_Customer' which fails to reparse. |
| 351 | + var sql = |
| 352 | + "ALTER TABLE Sales.SalesOrderDetail_inmem " + Environment.NewLine + |
| 353 | + " ADD CustomerID int NOT NULL DEFAULT -1 WITH VALUES, " + Environment.NewLine + |
| 354 | + " ShipMethodID int NOT NULL DEFAULT -1 WITH VALUES, " + Environment.NewLine + |
| 355 | + " INDEX ix_Customer (CustomerID); " + Environment.NewLine + |
| 356 | + "GO " + Environment.NewLine; |
| 357 | + |
| 358 | + var parser = new TSql170Parser(true); |
| 359 | + TSqlFragment fragment; |
| 360 | + IList<ParseError> parseErrors; |
| 361 | + using (var reader = new StringReader(sql)) |
| 362 | + { |
| 363 | + fragment = parser.Parse(reader, out parseErrors); |
| 364 | + } |
| 365 | + Assert.AreEqual(0, parseErrors.Count, "Input must parse."); |
| 366 | + |
| 367 | + var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions |
| 368 | + { |
| 369 | + IncludeSemicolons = true, |
| 370 | + }); |
| 371 | + generator.GenerateScript(fragment, out var generated); |
| 372 | + |
| 373 | + Assert.IsFalse(generated.Contains("NULLINDEX"), |
| 374 | + "Column constraint 'NULL' must not run into the 'INDEX' keyword. Actual:\n" + generated); |
| 375 | + Assert.IsTrue(generated.Contains("INDEX ix_Customer"), |
| 376 | + "INDEX keyword must be present and separated. Actual:\n" + generated); |
| 377 | + |
| 378 | + var reparser = new TSql170Parser(true); |
| 379 | + IList<ParseError> reparseErrors; |
| 380 | + using (var reader = new StringReader(generated)) |
| 381 | + { |
| 382 | + reparser.Parse(reader, out reparseErrors); |
| 383 | + } |
| 384 | + Assert.AreEqual(0, reparseErrors.Count, |
| 385 | + "Generated SQL must reparse. Actual:\n" + generated); |
| 386 | + } |
| 387 | + |
| 388 | + [TestMethod] |
| 389 | + [Priority(0)] |
| 390 | + [SqlStudioTestCategory(Category.UnitTest)] |
| 391 | + public void TestAlterTableAddIndexOnly_EmitsNoLeadingSeparator() |
| 392 | + { |
| 393 | + // Verbatim sample from docs/relational-databases/in-memory-oltp/ |
| 394 | + // altering-memory-optimized-tables.md (line 111). With no preceding |
| 395 | + // column or constraint, the generator must NOT emit a leading |
| 396 | + // comma before the INDEX (which would produce invalid syntax). |
| 397 | + var sql = |
| 398 | + "ALTER TABLE Sales.SalesOrderDetail_inmem " + Environment.NewLine + |
| 399 | + " ADD INDEX ix_ModifiedDate (ModifiedDate); " + Environment.NewLine + |
| 400 | + "GO " + Environment.NewLine; |
| 401 | + |
| 402 | + var parser = new TSql170Parser(true); |
| 403 | + TSqlFragment fragment; |
| 404 | + IList<ParseError> parseErrors; |
| 405 | + using (var reader = new StringReader(sql)) |
| 406 | + { |
| 407 | + fragment = parser.Parse(reader, out parseErrors); |
| 408 | + } |
| 409 | + Assert.AreEqual(0, parseErrors.Count, "Input must parse."); |
| 410 | + |
| 411 | + var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions |
| 412 | + { |
| 413 | + IncludeSemicolons = true, |
| 414 | + }); |
| 415 | + generator.GenerateScript(fragment, out var generated); |
| 416 | + |
| 417 | + Assert.IsFalse(generated.Contains("ADD ,") || generated.Contains("ADD\n,"), |
| 418 | + "ADD must not be followed by a stray separator. Actual:\n" + generated); |
| 419 | + Assert.IsTrue(generated.Contains("ADD INDEX ix_ModifiedDate"), |
| 420 | + "INDEX clause must follow ADD directly. Actual:\n" + generated); |
| 421 | + |
| 422 | + var reparser = new TSql170Parser(true); |
| 423 | + IList<ParseError> reparseErrors; |
| 424 | + using (var reader = new StringReader(generated)) |
| 425 | + { |
| 426 | + reparser.Parse(reader, out reparseErrors); |
| 427 | + } |
| 428 | + Assert.AreEqual(0, reparseErrors.Count, |
| 429 | + "Generated SQL must reparse. Actual:\n" + generated); |
| 430 | + } |
| 431 | + |
| 432 | + [TestMethod] |
| 433 | + [Priority(0)] |
| 434 | + [SqlStudioTestCategory(Category.UnitTest)] |
| 435 | + public void TestWindowDefinition_RefWindowFollowedByPartitionByEmitsSpace() |
| 436 | + { |
| 437 | + // Verbatim sample from docs/t-sql/queries/select-window-transact-sql.md |
| 438 | + // (line 312). Generator was emitting 'win2PARTITION' (no space) |
| 439 | + // because the visitor didn't separate the inherited window-name |
| 440 | + // reference from the PARTITION keyword. |
| 441 | + var sql = |
| 442 | + "ALTER DATABASE AdventureWorks2025" + Environment.NewLine + |
| 443 | + "SET COMPATIBILITY_LEVEL = 160;" + Environment.NewLine + |
| 444 | + "GO" + Environment.NewLine + |
| 445 | + Environment.NewLine + |
| 446 | + "USE AdventureWorks2025;" + Environment.NewLine + |
| 447 | + "GO" + Environment.NewLine + |
| 448 | + Environment.NewLine + |
| 449 | + "SELECT SalesOrderID AS OrderNumber," + Environment.NewLine + |
| 450 | + " ProductID," + Environment.NewLine + |
| 451 | + " OrderQty AS Qty," + Environment.NewLine + |
| 452 | + " SUM(OrderQty) OVER win2 AS Total," + Environment.NewLine + |
| 453 | + " AVG(OrderQty) OVER win1 AS Avg" + Environment.NewLine + |
| 454 | + "FROM Sales.SalesOrderDetail" + Environment.NewLine + |
| 455 | + "WHERE SalesOrderID IN (43659, 43664)" + Environment.NewLine + |
| 456 | + " AND ProductID LIKE '71%'" + Environment.NewLine + |
| 457 | + "WINDOW win1 AS (win3)," + Environment.NewLine + |
| 458 | + " win2 AS (ORDER BY SalesOrderID, ProductID)," + Environment.NewLine + |
| 459 | + " win3 AS (win2 PARTITION BY SalesOrderID);" + Environment.NewLine; |
| 460 | + |
| 461 | + var parser = new TSql170Parser(true); |
| 462 | + TSqlFragment fragment; |
| 463 | + IList<ParseError> parseErrors; |
| 464 | + using (var reader = new StringReader(sql)) |
| 465 | + { |
| 466 | + fragment = parser.Parse(reader, out parseErrors); |
| 467 | + } |
| 468 | + Assert.AreEqual(0, parseErrors.Count, "Input must parse."); |
| 469 | + |
| 470 | + var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions |
| 471 | + { |
| 472 | + IncludeSemicolons = true, |
| 473 | + }); |
| 474 | + generator.GenerateScript(fragment, out var generated); |
| 475 | + |
| 476 | + Assert.IsFalse(generated.Contains("win2PARTITION"), |
| 477 | + "Window-name reference must not run into PARTITION keyword. Actual:\n" + generated); |
| 478 | + Assert.IsTrue(generated.Contains("win2 PARTITION"), |
| 479 | + "Generated window must read 'win2 PARTITION'. Actual:\n" + generated); |
| 480 | + |
| 481 | + var reparser = new TSql170Parser(true); |
| 482 | + IList<ParseError> reparseErrors; |
| 483 | + using (var reader = new StringReader(generated)) |
| 484 | + { |
| 485 | + reparser.Parse(reader, out reparseErrors); |
| 486 | + } |
| 487 | + Assert.AreEqual(0, reparseErrors.Count, |
| 488 | + "Generated SQL must reparse. Actual:\n" + generated); |
| 489 | + } |
| 490 | + |
| 491 | + [TestMethod] |
| 492 | + [Priority(0)] |
| 493 | + [SqlStudioTestCategory(Category.UnitTest)] |
| 494 | + public void TestWindowDefinition_RefWindowFollowedByOrderByEmitsSpace() |
| 495 | + { |
| 496 | + // Same bug class as above, exercised through the ORDER BY branch. |
| 497 | + // 'WINDOW name AS (refname ORDER BY ...)' must not emit |
| 498 | + // 'refnameORDER'. This shape isn't in the docs but the same code |
| 499 | + // path can produce it; included as belt-and-suspenders coverage. |
| 500 | + var sql = |
| 501 | + "SELECT SalesOrderID, SUM(OrderQty) OVER win2 AS Total" + Environment.NewLine + |
| 502 | + "FROM Sales.SalesOrderDetail" + Environment.NewLine + |
| 503 | + "WINDOW win1 AS (PARTITION BY ProductID)," + Environment.NewLine + |
| 504 | + " win2 AS (win1 ORDER BY SalesOrderID);"; |
| 505 | + |
| 506 | + var parser = new TSql170Parser(true); |
| 507 | + TSqlFragment fragment; |
| 508 | + IList<ParseError> parseErrors; |
| 509 | + using (var reader = new StringReader(sql)) |
| 510 | + { |
| 511 | + fragment = parser.Parse(reader, out parseErrors); |
| 512 | + } |
| 513 | + Assert.AreEqual(0, parseErrors.Count, "Input must parse."); |
| 514 | + |
| 515 | + var generator = new Sql170ScriptGenerator(new SqlScriptGeneratorOptions |
| 516 | + { |
| 517 | + IncludeSemicolons = true, |
| 518 | + }); |
| 519 | + generator.GenerateScript(fragment, out var generated); |
| 520 | + |
| 521 | + Assert.IsFalse(generated.Contains("win1ORDER"), |
| 522 | + "Window-name reference must not run into ORDER keyword. Actual:\n" + generated); |
| 523 | + Assert.IsTrue(generated.Contains("win1 ORDER"), |
| 524 | + "Generated window must read 'win1 ORDER'. Actual:\n" + generated); |
| 525 | + |
| 526 | + var reparser = new TSql170Parser(true); |
| 527 | + IList<ParseError> reparseErrors; |
| 528 | + using (var reader = new StringReader(generated)) |
| 529 | + { |
| 530 | + reparser.Parse(reader, out reparseErrors); |
| 531 | + } |
| 532 | + Assert.AreEqual(0, reparseErrors.Count, |
| 533 | + "Generated SQL must reparse. Actual:\n" + generated); |
| 534 | + } |
| 535 | + |
| 536 | + #endregion |
| 537 | + |
338 | 538 | #region Comment Preservation Tests |
339 | 539 |
|
340 | 540 | [TestMethod] |
|
0 commit comments