Skip to content

Commit 2641cbf

Browse files
authored
Fix generator whitespace bugs: ALTER TABLE ADD INDEX and WINDOW name reference (#208)
* Fix generator whitespace bugs: ALTER TABLE ADD INDEX and WINDOW name reference Two unrelated pre-existing generator bugs surfaced by round-tripping the sql-docs corpus: 1. AlterTableAddTableElementStatement omitted the separator between column-definitions/constraints and table indexes, producing 'NOT NULLINDEX ix_...' which fails to reparse. 2. WindowDefinition omitted whitespace between the inherited window-name reference (RefWindowName) and a following PARTITION BY or ORDER BY, producing 'win2PARTITION ...' which fails to reparse. Both fixes are local to their visitors and follow the same separator patterns already present in sibling code paths. Adds three focused regression tests under a new 'Generator Whitespace Regression Tests' region. * Address feedback
1 parent a7497c8 commit 2641cbf

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.AlterTableAddTableElementStatement.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ public override void ExplicitVisit(AlterTableAddTableElementStatement node)
6161
ExplicitVisit((SystemTimePeriodDefinition)node.Definition.SystemTimePeriod);
6262
}
6363

64+
// Separate preceding elements from inline indexes with a comma
65+
// and newline to avoid token concatenation (e.g. 'NOT NULLINDEX').
66+
if ((node.Definition.ColumnDefinitions.Count > 0
67+
|| node.Definition.TableConstraints.Count > 0
68+
|| node.Definition.SystemTimePeriod != null)
69+
&& node.Definition.Indexes != null
70+
&& node.Definition.Indexes.Count > 0)
71+
{
72+
GenerateSymbolAndSpace(TSqlTokenType.Comma);
73+
NewLine();
74+
}
75+
6476
if (node.Definition.Indexes != null && node.Definition.Indexes.Count > 0)
6577
{
6678
GenerateCommaSeparatedList(node.Definition.Indexes, true);

SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.WindowDefinition.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ public override void ExplicitVisit(WindowDefinition node)
1818

1919
GenerateFragmentIfNotNull(node.RefWindowName);
2020
bool partitionByClauseExists = node.Partitions.Count > 0;
21+
22+
// 'win2PARTITION' / 'win2ORDER' would otherwise tokenize as one identifier.
23+
if (node.RefWindowName != null && (partitionByClauseExists || node.OrderByClause != null))
24+
{
25+
GenerateSpace();
26+
}
27+
2128
if (partitionByClauseExists)
2229
{
2330
GenerateIdentifier(CodeGenerationSupporter.Partition);

Test/SqlDom/ScriptGeneratorTests.cs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//------------------------------------------------------------------------------
66

77
using System;
8+
using System.Collections.Generic;
89
using System.IO;
910
using Microsoft.SqlServer.TransactSql.ScriptDom;
1011
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -335,6 +336,205 @@ void ParseAndAssertEquality(string sqlText, SqlScriptGeneratorOptions generatorO
335336
Assert.AreEqual(sqlText, generatedSqlText);
336337
}
337338

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+
338538
#region Comment Preservation Tests
339539

340540
[TestMethod]

0 commit comments

Comments
 (0)