Skip to content

Commit 849cdfb

Browse files
committed
feat: enhance normalization for legacy TableData scripts to support multi-line INSERT statements and update related tests
1 parent 20a5ae1 commit 849cdfb

4 files changed

Lines changed: 213 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
99

1010
### Fixed
1111
- Trailing semicolon differences on `INSERT` statement lines in data scripts are now suppressed during comparison normalization; scripts emitted with and without statement terminators compare as compatible (#47).
12-
- Legacy `TableData` scripts now compare as compatible when they differ from canonical output only by `SET IDENTITY_INSERT` semicolons or top-level `N'...'` string literal prefixes.
12+
- Legacy `TableData` scripts now compare as compatible when they differ from canonical output only by `SET IDENTITY_INSERT` semicolons or top-level `N'...'` string literal prefixes, including inside multi-line `INSERT ... VALUES (...)` statements.
1313
- Whitespace-only separator lines now compare as compatible with empty blank lines during `status` and `diff`.
1414
- Preserve reference banner-comment formatting and module-declaration identifier quoting during programmable-object compatibility reconciliation.
1515
- Preserve compatible computed-column arithmetic grouping parentheses during table compatibility reconciliation.

specs/04-scripting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ When compatibility reference files are available, `sqlct` MAY apply reconciliati
779779
- Whitespace-only lines MUST be normalized to empty lines during comparison so that blank separators differing only by spaces or tabs compare as compatible.
780780
- Trailing semicolons on `INSERT` statement lines MUST be stripped by comparison normalization so that scripts emitted with and without statement terminators compare as compatible.
781781
- For `TableData`, trailing semicolons on `SET IDENTITY_INSERT` lines MUST also be stripped by comparison normalization.
782-
- For `TableData`, comparison normalization MUST treat legacy top-level `N'...'` string literals inside `INSERT ... VALUES (...)` as compatible with canonical `'...'` literals; canonical script generation remains governed by Section 8.26.
782+
- For `TableData`, comparison normalization MUST treat legacy top-level `N'...'` string literals inside single-line or multi-line `INSERT ... VALUES (...)` statements as compatible with canonical `'...'` literals; canonical script generation remains governed by Section 8.26.
783783

784784
## 11. Error and Unsupported Behavior
785785
- Missing SQL object metadata for requested object MUST fail with an error.

src/SqlChangeTracker/Sync/SyncCommandService.cs

Lines changed: 172 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,25 +1792,28 @@ internal static string NormalizeForComparison(string script, string? objectType)
17921792
}
17931793

17941794
var isTableData = string.Equals(objectType, TableDataObjectType, StringComparison.OrdinalIgnoreCase);
1795-
if (!normalized.Contains("INSERT ", StringComparison.OrdinalIgnoreCase) &&
1796-
(!isTableData || !normalized.Contains("SET IDENTITY_INSERT ", StringComparison.OrdinalIgnoreCase)))
1795+
var joined = string.Join("\n", lines);
1796+
if (isTableData)
17971797
{
1798-
return string.Join("\n", lines);
1798+
return !joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase) &&
1799+
!joined.Contains("SET IDENTITY_INSERT ", StringComparison.OrdinalIgnoreCase)
1800+
? joined
1801+
: NormalizeLegacyTableDataScript(joined);
1802+
}
1803+
1804+
if (!joined.Contains("INSERT ", StringComparison.OrdinalIgnoreCase))
1805+
{
1806+
return joined;
17991807
}
18001808

18011809
for (var i = 0; i < lines.Length; i++)
18021810
{
18031811
var line = lines[i];
1804-
if (line.EndsWith(';') && (LineStartsWithInsert(line) || (isTableData && LineStartsWithIdentityInsert(line))))
1812+
if (line.EndsWith(';') && LineStartsWithInsert(line))
18051813
{
18061814
line = line[..^1];
18071815
}
18081816

1809-
if (isTableData && LineStartsWithInsert(line))
1810-
{
1811-
line = NormalizeLegacyTableDataInsertLine(line);
1812-
}
1813-
18141817
lines[i] = line;
18151818
}
18161819

@@ -1837,7 +1840,165 @@ private static bool LineStartsWithIdentityInsert(string line)
18371840
line.AsSpan(pos, prefix.Length).Equals(prefix, StringComparison.OrdinalIgnoreCase);
18381841
}
18391842

1840-
private static string NormalizeLegacyTableDataInsertLine(string line)
1843+
private static string NormalizeLegacyTableDataScript(string script)
1844+
{
1845+
var builder = new StringBuilder(script.Length);
1846+
var position = 0;
1847+
while (position < script.Length)
1848+
{
1849+
var lineEnd = script.IndexOf('\n', position);
1850+
var segmentEnd = lineEnd >= 0 ? lineEnd : script.Length;
1851+
var line = script.Substring(position, segmentEnd - position);
1852+
1853+
if (LineStartsWithIdentityInsert(line))
1854+
{
1855+
builder.Append(StripTrailingSemicolon(line));
1856+
if (lineEnd >= 0)
1857+
{
1858+
builder.Append('\n');
1859+
position = lineEnd + 1;
1860+
}
1861+
else
1862+
{
1863+
position = script.Length;
1864+
}
1865+
1866+
continue;
1867+
}
1868+
1869+
if (LineStartsWithInsert(line))
1870+
{
1871+
var insertRange = TryFindInsertValuesStatementRange(script, position);
1872+
if (insertRange.HasValue)
1873+
{
1874+
var statement = script.Substring(
1875+
position,
1876+
insertRange.Value.StatementEndExclusive - position);
1877+
builder.Append(NormalizeLegacyTableDataInsertStatement(statement));
1878+
position = insertRange.Value.ConsumedEndExclusive;
1879+
continue;
1880+
}
1881+
}
1882+
1883+
builder.Append(line);
1884+
if (lineEnd >= 0)
1885+
{
1886+
builder.Append('\n');
1887+
position = lineEnd + 1;
1888+
}
1889+
else
1890+
{
1891+
position = script.Length;
1892+
}
1893+
}
1894+
1895+
return builder.ToString();
1896+
}
1897+
1898+
private static string StripTrailingSemicolon(string line)
1899+
=> line.EndsWith(';') ? line[..^1] : line;
1900+
1901+
private static (int StatementEndExclusive, int ConsumedEndExclusive)? TryFindInsertValuesStatementRange(
1902+
string script,
1903+
int start)
1904+
{
1905+
var valuesKeywordIndex = script.IndexOf("VALUES", start, StringComparison.OrdinalIgnoreCase);
1906+
if (valuesKeywordIndex < 0)
1907+
{
1908+
return null;
1909+
}
1910+
1911+
var valuesOpenParenIndex = script.IndexOf('(', valuesKeywordIndex);
1912+
if (valuesOpenParenIndex < 0)
1913+
{
1914+
return null;
1915+
}
1916+
1917+
var inSingleQuotedString = false;
1918+
var inBracketedIdentifier = false;
1919+
var parenDepth = 0;
1920+
for (var i = valuesOpenParenIndex; i < script.Length; i++)
1921+
{
1922+
var ch = script[i];
1923+
if (inSingleQuotedString)
1924+
{
1925+
if (ch == '\'')
1926+
{
1927+
if (i + 1 < script.Length && script[i + 1] == '\'')
1928+
{
1929+
i++;
1930+
}
1931+
else
1932+
{
1933+
inSingleQuotedString = false;
1934+
}
1935+
}
1936+
1937+
continue;
1938+
}
1939+
1940+
if (inBracketedIdentifier)
1941+
{
1942+
if (ch == ']')
1943+
{
1944+
inBracketedIdentifier = false;
1945+
}
1946+
1947+
continue;
1948+
}
1949+
1950+
if (ch == '[')
1951+
{
1952+
inBracketedIdentifier = true;
1953+
continue;
1954+
}
1955+
1956+
if (ch == '\'')
1957+
{
1958+
inSingleQuotedString = true;
1959+
continue;
1960+
}
1961+
1962+
if (ch == '(')
1963+
{
1964+
parenDepth++;
1965+
continue;
1966+
}
1967+
1968+
if (ch != ')')
1969+
{
1970+
continue;
1971+
}
1972+
1973+
parenDepth--;
1974+
if (parenDepth != 0)
1975+
{
1976+
continue;
1977+
}
1978+
1979+
var statementEndExclusive = i + 1;
1980+
var consumedEndExclusive = statementEndExclusive;
1981+
while (consumedEndExclusive < script.Length && script[consumedEndExclusive] is ' ' or '\t')
1982+
{
1983+
consumedEndExclusive++;
1984+
}
1985+
1986+
if (consumedEndExclusive < script.Length && script[consumedEndExclusive] == ';')
1987+
{
1988+
consumedEndExclusive++;
1989+
}
1990+
else
1991+
{
1992+
consumedEndExclusive = statementEndExclusive;
1993+
}
1994+
1995+
return (statementEndExclusive, consumedEndExclusive);
1996+
}
1997+
1998+
return null;
1999+
}
2000+
2001+
private static string NormalizeLegacyTableDataInsertStatement(string line)
18412002
{
18422003
var valuesKeywordIndex = line.IndexOf("VALUES", StringComparison.OrdinalIgnoreCase);
18432004
if (valuesKeywordIndex < 0)
@@ -1938,7 +2099,7 @@ private static bool IsTopLevelInsertStringPrefixBoundary(string line, int values
19382099
for (var i = prefixIndex - 1; i > valuesOpenParenIndex; i--)
19392100
{
19402101
var ch = line[i];
1941-
if (ch is ' ' or '\t')
2102+
if (char.IsWhiteSpace(ch))
19422103
{
19432104
continue;
19442105
}

tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,45 @@ public void BuildUnifiedDiff_TableData_SuppressesLegacyIdentityInsertAndUnicodeL
841841
Assert.Empty(diff);
842842
}
843843

844+
[Fact]
845+
public void NormalizeForComparison_TableData_NormalizesLegacyPrefixesInMultilineInsertValues()
846+
{
847+
var canonical = SyncCommandService.NormalizeForComparison(
848+
"INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" +
849+
"Line 2\n" +
850+
"0 - empty', 'ScoreApl', 'EmploymentBasis', 0);",
851+
SyncCommandService.TableDataObjectType);
852+
var legacy = SyncCommandService.NormalizeForComparison(
853+
"INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" +
854+
"Line 2\n" +
855+
"0 - empty', N'ScoreApl', N'EmploymentBasis', 0)",
856+
SyncCommandService.TableDataObjectType);
857+
858+
Assert.Equal(canonical, legacy);
859+
}
860+
861+
[Fact]
862+
public void BuildUnifiedDiff_TableData_SuppressesLegacyPrefixesInMultilineInsertValues()
863+
{
864+
var source =
865+
"INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" +
866+
"Line 2\n" +
867+
"0 - empty', 'ScoreApl', 'EmploymentBasis', 0);";
868+
var target =
869+
"INSERT INTO [dbo].[Variable] ([Description], [GroupCode], [Name], [Flag]) VALUES ('Line 1\n" +
870+
"Line 2\n" +
871+
"0 - empty', N'ScoreApl', N'EmploymentBasis', 0)";
872+
873+
var diff = SyncCommandService.BuildUnifiedDiff(
874+
SyncCommandService.TableDataObjectType,
875+
"db",
876+
"folder",
877+
source,
878+
target);
879+
880+
Assert.Empty(diff);
881+
}
882+
844883
[Fact]
845884
public void NormalizeForComparison_DoesNotNormalizeUnicodeLiteralPrefixesOutsideTableData()
846885
{

0 commit comments

Comments
 (0)