From be0044ae6a2ebb599b6529d466d95562c8b62b3f Mon Sep 17 00:00:00 2001 From: Leila Lali Date: Fri, 16 Oct 2026 14:40:44 -0700 Subject: [PATCH] Fix COPY option formatting and round-trip handling Use standard fragment writers for COPY option strings and identifiers while retaining existing casing behavior. Add round-trip coverage across parser versions and formatting modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SqlScriptGeneratorVisitor.CopyOption.cs | 49 ++++--- .../ScriptGenerator/ScriptGeneratorTests.cs | 126 ++++++++++++++++++ 2 files changed, 158 insertions(+), 17 deletions(-) diff --git a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CopyOption.cs b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CopyOption.cs index 94a4b11c..7678371e 100644 --- a/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CopyOption.cs +++ b/SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.CopyOption.cs @@ -4,7 +4,6 @@ // //------------------------------------------------------------------------------ using System; -using System.Globalization; namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator { @@ -26,14 +25,13 @@ public override void ExplicitVisit(CopyOption node) break; } GenerateSymbol(TSqlTokenType.LeftParenthesis); - string identityValue = ExtractStringValue(((CopyCredentialOption)node.Value).Identity); - GenerateNameEqualsValue("Identity", string.Format(CultureInfo.InvariantCulture, "'{0}'", identityValue)); + CopyCredentialOption credential = (CopyCredentialOption)node.Value; + GenerateNameEqualsValue("Identity", GetCopyOptionStringLiteral(credential.Identity)); - if (((CopyCredentialOption)node.Value).Secret != null) + if (credential.Secret != null) { GenerateSymbol(TSqlTokenType.Comma); - string secretValue = ExtractStringValue(((CopyCredentialOption)node.Value).Secret); - GenerateNameEqualsValue("Secret", string.Format(CultureInfo.InvariantCulture, "'{0}'", secretValue)); + GenerateNameEqualsValue("Secret", GetCopyOptionStringLiteral(credential.Secret)); } GenerateSymbol(TSqlTokenType.RightParenthesis); NewLine(); @@ -48,29 +46,46 @@ public override void ExplicitVisit(CopyOption node) } else { - string optionValue = ExtractStringValue(node.Value); - - if (node.Kind == CopyOptionKind.FirstRow || node.Kind == CopyOptionKind.MaxErrors || node.Kind == CopyOptionKind.File_Format) - GenerateNameEqualsValue(node.Kind.ToString(), string.Format(CultureInfo.InvariantCulture, "{0}", optionValue)); + if (node.Kind == CopyOptionKind.FirstRow || node.Kind == CopyOptionKind.MaxErrors) + GenerateNameEqualsValue(node.Kind.ToString(), ExtractStringValue(node.Value)); + else if (node.Kind == CopyOptionKind.File_Format) + { + Identifier identifier = ((SingleValueTypeCopyOption)node.Value).SingleValue.Identifier; + GenerateNameEqualsValue(node.Kind.ToString(), new Identifier + { + Value = ScriptGeneratorSupporter.GetCasedString(identifier.Value, _options.KeywordCasing), + QuoteType = identifier.QuoteType + }); + } else - GenerateNameEqualsValue(node.Kind.ToString(), string.Format(CultureInfo.InvariantCulture, "'{0}'", optionValue)); + GenerateNameEqualsValue(node.Kind.ToString(), GetCopyOptionStringLiteral( + (StringLiteral)((SingleValueTypeCopyOption)node.Value).SingleValue.ValueExpression)); } NewLine(); } } /// - /// Extracts value from copy credential option. + /// Creates a string literal with the existing COPY option casing. /// - /// Copy option. - /// Extracted string. - private string ExtractStringValue(StringLiteral credOption) + /// Copy option literal. + /// A cased copy of the literal. + private StringLiteral GetCopyOptionStringLiteral(StringLiteral literal) { - if (credOption == null) + if (literal == null) { throw new ArgumentNullException("Invalid Credential values"); } - return credOption.Value; + + // Legacy PascalCase applied to the leading quote, leaving the value lowercase. + KeywordCasing casing = _options.KeywordCasing == KeywordCasing.PascalCase + ? KeywordCasing.Lowercase : _options.KeywordCasing; + return new StringLiteral + { + Value = ScriptGeneratorSupporter.GetCasedString(literal.Value, casing), + IsNational = literal.IsNational, + Collation = literal.Collation + }; } /// diff --git a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs index 72f0b680..d66656b6 100644 --- a/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs +++ b/Test/SqlDom/ScriptGenerator/ScriptGeneratorTests.cs @@ -17,6 +17,132 @@ namespace SqlStudio.Tests.UTSqlScriptDom [TestClass] public class SqlScriptGeneratorTests { + [DataTestMethod] + [DataRow("ERRORFILE = 'reader''s/files'", "ErrorFile = 'READER''S/FILES'")] + [DataRow("ERRORFILE = '''reader''''s'''", "ErrorFile = '''READER''''S'''")] + [DataRow("ERRORFILE = N'r\u00e9ader''s/files'", "ErrorFile = N'R\u00c9ADER''S/FILES'")] + [DataRow("FIELDTERMINATOR = 'a''b'", "FieldTerminator = 'A''B'")] + [DataRow("ROWTERMINATOR = 'a''''b'", "RowTerminator = 'A''''B'")] + [DataRow("FIELDQUOTE = ''''", "FieldQuote = ''''")] + [DataRow("FIELDQUOTE = ''", "FieldQuote = ''")] + [DataRow("FIELDTERMINATOR = '0x0a'", "FieldTerminator = '0X0A'")] + [DataRow("FILE_TYPE = 'csv'", "File_Type = 'CSV'")] + [DataRow("COMPRESSION = 'gzip'", "Compression = 'GZIP'")] + [DataRow("DATEFORMAT = 'ymd'", "DateFormat = 'YMD'")] + [DataRow("ENCODING = 'utf8'", "Encoding = 'UTF8'")] + [DataRow("IDENTITY_INSERT = 'on'", "Identity_Insert = 'ON'")] + [DataRow("FIRSTROW = 2", "FirstRow = 2")] + [DataRow("MAXERRORS = 0", "MaxErrors = 0")] + [DataRow("FILE_FORMAT = fileformat", "File_Format = FILEFORMAT")] + [DataRow("FILE_FORMAT = [reader's format]", "File_Format = [READER'S FORMAT]")] + [DataRow("FILE_FORMAT = [reader]]s]", "File_Format = [READER]]S]")] + [DataRow("FILE_FORMAT = \"reader\"\"s format\"", "File_Format = \"READER\"\"S FORMAT\"")] + [DataRow("FILE_FORMAT = [select]", "File_Format = [SELECT]")] + [DataRow("FILE_FORMAT = [format;(1)]", "File_Format = [FORMAT;(1)]")] + [DataRow("CREDENTIAL = (IDENTITY = 'Shared Access Signature', SECRET = 'reader''s''''key')", + "Credential = (Identity = 'SHARED ACCESS SIGNATURE',Secret = 'READER''S''''KEY')")] + [DataRow("ERRORFILE_CREDENTIAL = (IDENTITY = 'Shared Access Signature', SECRET = '''key''')", + "ERRORFILE_CREDENTIAL = (Identity = 'SHARED ACCESS SIGNATURE',Secret = '''KEY''')")] + [DataRow("CREDENTIAL = (IDENTITY = N'Shared Access Signature', SECRET = N'key''value')", + "Credential = (Identity = N'SHARED ACCESS SIGNATURE',Secret = N'KEY''VALUE')")] + [DataRow("CREDENTIAL = (IDENTITY = 'Managed Identity')", + "Credential = (Identity = 'MANAGED IDENTITY')")] + [DataRow("ERRORFILE_CREDENTIAL = (IDENTITY = 'Managed Identity')", + "ERRORFILE_CREDENTIAL = (Identity = 'MANAGED IDENTITY')")] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void CopyOptionRoundTrip(string option, string expectedOption) + { + SqlVersion[] versions = + { + SqlVersion.Sql130, SqlVersion.Sql140, SqlVersion.Sql150, SqlVersion.Sql160, + SqlVersion.Sql170, SqlVersion.Sql180, SqlVersion.SqlFabricDW + }; + + foreach (SqlVersion version in versions) + { + VerifyCopyOptionRoundTrip(TSqlParser.CreateParser(version, true), + ParserTestUtils.CreateScriptGen(version), option, expectedOption); + } + + VerifyCopyOptionRoundTrip(new TSql160Parser(true), new SqlServerlessScriptGenerator(), + option, expectedOption); + } + + [DataTestMethod] + [DataRow(KeywordCasing.Uppercase, "ErrorFile = 'READER''S/FILES'", "File_Format = [MY FORMAT]")] + [DataRow(KeywordCasing.Lowercase, "ErrorFile = 'reader''s/files'", "File_Format = [my format]")] + [DataRow(KeywordCasing.PascalCase, "ErrorFile = 'reader''s/files'", "File_Format = [My format]")] + [Priority(0)] + [SqlStudioTestCategory(Category.UnitTest)] + public void CopyOptionCasing(KeywordCasing casing, string expectedLiteral, string expectedIdentifier) + { + var generator = new Sql160ScriptGenerator(new SqlScriptGeneratorOptions { KeywordCasing = casing }); + VerifyCopyOptionRoundTrip(new TSql160Parser(true), generator, + "ERRORFILE = 'Reader''s/Files'", expectedLiteral); + VerifyCopyOptionRoundTrip(new TSql160Parser(true), generator, + "FILE_FORMAT = [My Format]", expectedIdentifier); + } + + private static void VerifyCopyOptionRoundTrip(TSqlParser parser, SqlScriptGenerator generator, + string option, string expectedOption) + { + string sql = "COPY INTO dbo.t FROM 'https://example.invalid/data.csv' WITH (" + option + ");"; + var script = (TSqlScript)parser.Parse(new StringReader(sql), out var errors); + Assert.AreEqual(0, errors.Count, sql); + var original = (CopyStatement)script.Batches[0].Statements[0]; + var originalCredential = original.Options[0].Value as CopyCredentialOption; + var originalSingleValue = (original.Options[0].Value as SingleValueTypeCopyOption)?.SingleValue; + string originalIdentity = originalCredential?.Identity.Value; + string originalSecret = originalCredential?.Secret?.Value; + string originalValue = originalSingleValue?.Value; + QuoteType? originalQuoteType = originalSingleValue?.Identifier?.QuoteType; + + generator.GenerateScript(script, out string generated); + StringAssert.Contains(generated, expectedOption, parser.GetType().Name); + Assert.AreEqual(originalIdentity, originalCredential?.Identity.Value); + Assert.AreEqual(originalSecret, originalCredential?.Secret?.Value); + Assert.AreEqual(originalValue, originalSingleValue?.Value); + Assert.AreEqual(originalQuoteType, originalSingleValue?.Identifier?.QuoteType); + + var reparsed = (TSqlScript)parser.Parse(new StringReader(generated), out var reparseErrors); + Assert.AreEqual(0, reparseErrors.Count, generated); + Assert.AreEqual(1, reparsed.Batches.Count, generated); + Assert.AreEqual(1, reparsed.Batches[0].Statements.Count, generated); + Assert.IsInstanceOfType(reparsed.Batches[0].Statements[0], typeof(CopyStatement)); + var copy = (CopyStatement)reparsed.Batches[0].Statements[0]; + Assert.AreEqual(1, copy.Options.Count); + Assert.AreEqual(original.Options[0].Kind, copy.Options[0].Kind); + + var expected = (TSqlScript)parser.Parse(new StringReader( + "COPY INTO dbo.t FROM 'https://example.invalid/data.csv' WITH (" + expectedOption + ");"), + out var expectedErrors); + Assert.AreEqual(0, expectedErrors.Count); + var expectedCopy = (CopyStatement)expected.Batches[0].Statements[0]; + if (copy.Options[0].Value is CopyCredentialOption credential) + { + var expectedCredential = (CopyCredentialOption)expectedCopy.Options[0].Value; + Assert.AreEqual(expectedCredential.Identity.Value, credential.Identity.Value); + Assert.AreEqual(expectedCredential.Identity.IsNational, credential.Identity.IsNational); + Assert.AreEqual(expectedCredential.Secret?.Value, credential.Secret?.Value); + Assert.AreEqual(expectedCredential.Secret?.IsNational, credential.Secret?.IsNational); + } + else + { + var actualValue = ((SingleValueTypeCopyOption)copy.Options[0].Value).SingleValue; + var expectedValue = ((SingleValueTypeCopyOption)expectedCopy.Options[0].Value).SingleValue; + Assert.AreEqual(expectedValue.Value, actualValue.Value); + Assert.AreEqual(expectedValue.Identifier?.QuoteType, actualValue.Identifier?.QuoteType); + Assert.AreEqual((expectedValue.ValueExpression as StringLiteral)?.IsNational, + (actualValue.ValueExpression as StringLiteral)?.IsNational); + } + + generator.GenerateScript(reparsed, out string regenerated); + Assert.AreEqual(generated, regenerated); + generator.GenerateScript(script, out string generatedAgain); + Assert.AreEqual(generated, generatedAgain); + } + [TestMethod] [Priority(0)] [SqlStudioTestCategory(Category.UnitTest)]