From 9990a302122d3f26e97151146266400478e21e5d Mon Sep 17 00:00:00 2001 From: pierreln-dd Date: Tue, 30 Jun 2026 12:10:08 +0200 Subject: [PATCH] fix(normalizer): preserve bracket-quoted identifiers containing spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bracket-quoted T-SQL identifiers whose content contains whitespace (e.g. [FPA EAM NAME]) were incorrectly de-bracketed in column/table reference position, producing invalid SQL like `CSC. FPA EAM NAME`. Two fixes: 1. shouldStripIdentifierQuotes: refuse to strip when token.Value contains whitespace — stripped content would yield multiple SQL tokens and break the query. 2. appendSpace: suppress the inter-token space when the preceding token ends with '.' and the current token is a QUOTED_IDENT — this handles the split that the lexer performs for constructs like `CSC.[FPA EAM NAME]` (lexed as `CSC.` + `[FPA EAM NAME]`). Simple identifiers ([public], [users]) and multi-part identifiers ([public].[users]) are unaffected — they contain no whitespace and continue to be de-bracketed as before. Co-Authored-By: Claude Sonnet 4.6 --- normalizer.go | 11 +++++++ normalizer_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/normalizer.go b/normalizer.go index 7563e3d..a893c43 100644 --- a/normalizer.go +++ b/normalizer.go @@ -408,6 +408,11 @@ func (n *Normalizer) shouldStripIdentifierQuotes(token *Token, lastValueToken *L if isAliasContext(lastValueToken) && !token.isSimpleIdentifier { return false } + // Bracket-quoted identifiers whose content contains whitespace cannot be + // safely unquoted: the stripped form would produce multiple SQL tokens. + if strings.ContainsAny(token.Value, " \t\n\r") { + return false + } return true } @@ -470,6 +475,12 @@ func (n *Normalizer) appendSpace(token *Token, lastValueToken *LastValueToken, n return } + // do not add a space between a dot-suffixed identifier and the following identifier, + // because the lexer splits e.g. t.[Col] into "t." and "[Col]" as separate tokens + if lastValueToken != nil && len(lastValueToken.Value) > 1 && lastValueToken.Value[len(lastValueToken.Value)-1] == '.' && (token.Type == IDENT || token.Type == QUOTED_IDENT) { + return + } + // do not add a space after a colon when followed by an identifier or quoted identifier, // BUT only if the token before the colon was NOT an identifier (to preserve MySQL labels and Snowflake semi-structured access) // This handles Oracle bind variables like :param or :"param" diff --git a/normalizer_test.go b/normalizer_test.go index 79fbda1..d6d842a 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -1041,6 +1041,85 @@ func TestNormalizeDeobfuscatedSQL(t *testing.T) { WithDBMS(DBMSSQLServer), }, }, + { + // simple bracket-quoted column after dot — no space, brackets stripped, metadata on + input: `SELECT t.[SimpleCol] FROM dbo.SomeTable AS t`, + expected: `SELECT t.SimpleCol FROM dbo.SomeTable AS t`, + statementMetadata: StatementMetadata{ + Tables: []string{`dbo.SomeTable`}, + Comments: []string{}, + Commands: []string{"SELECT"}, + Procedures: []string{}, + Size: 19, + }, + normalizationConfig: &normalizerConfig{ + CollectComments: true, + CollectCommands: true, + CollectTables: true, + KeepSQLAlias: true, + }, + lexerOptions: []lexerOption{ + WithDBMS(DBMSSQLServer), + }, + }, + { + // same as above with all metadata collection disabled — output must be identical + input: `SELECT t.[SimpleCol] FROM dbo.SomeTable AS t`, + expected: `SELECT t.SimpleCol FROM dbo.SomeTable AS t`, + statementMetadata: StatementMetadata{ + Tables: []string{}, + Comments: []string{}, + Commands: []string{}, + Procedures: []string{}, + Size: 0, + }, + normalizationConfig: &normalizerConfig{ + KeepSQLAlias: true, + }, + lexerOptions: []lexerOption{ + WithDBMS(DBMSSQLServer), + }, + }, + { + input: `SELECT t.[Column With Spaces] FROM dbo.SomeTable AS t`, + expected: `SELECT t.[Column With Spaces] FROM dbo.SomeTable AS t`, + statementMetadata: StatementMetadata{ + Tables: []string{`dbo.SomeTable`}, + Comments: []string{}, + Commands: []string{"SELECT"}, + Procedures: []string{}, + Size: 19, + }, + normalizationConfig: &normalizerConfig{ + CollectComments: true, + CollectCommands: true, + CollectTables: true, + KeepSQLAlias: true, + }, + lexerOptions: []lexerOption{ + WithDBMS(DBMSSQLServer), + }, + }, + { + input: `SELECT [First Name], [Last Name] FROM [My Table]`, + expected: `SELECT [First Name], [Last Name] FROM [My Table]`, + statementMetadata: StatementMetadata{ + Tables: []string{`My Table`}, + Comments: []string{}, + Commands: []string{"SELECT"}, + Procedures: []string{}, + Size: 14, + }, + normalizationConfig: &normalizerConfig{ + CollectComments: true, + CollectCommands: true, + CollectTables: true, + KeepSQLAlias: true, + }, + lexerOptions: []lexerOption{ + WithDBMS(DBMSSQLServer), + }, + }, { input: `( @p1 bigint ) SELECT * from dbm_user as prepared_user WHERE id = @p1`, expected: `SELECT * from dbm_user as prepared_user WHERE id = @p1`,