Skip to content

Commit 9261423

Browse files
kyleconroyclaude
andcommitted
Handle escape sequences in backtick identifiers and sanitize invalid UTF-8
1. Lexer: Process escape sequences (\xFF, \0, etc.) in backtick-quoted identifiers, matching the behavior of string literals. 2. Explain output: - Replace invalid UTF-8 bytes with replacement character (U+FFFD) - Display null bytes as escape sequence \0 - Escape backslashes and single quotes in identifier/alias output 3. Apply sanitization to: - Column declarations - Identifier names - Function aliases - Storage definition ORDER BY identifiers This matches ClickHouse's EXPLAIN AST behavior for handling special characters in identifiers. Fixes test: 03356_tables_with_binary_identifiers_invalid_utf8/stmt2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 599dee5 commit 9261423

6 files changed

Lines changed: 119 additions & 18 deletions

File tree

internal/explain/explain.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,9 @@ func Column(sb *strings.Builder, col *ast.ColumnDeclaration, depth int) {
350350
children++
351351
}
352352
if children > 0 {
353-
fmt.Fprintf(sb, "%sColumnDeclaration %s (children %d)\n", indent, col.Name, children)
353+
fmt.Fprintf(sb, "%sColumnDeclaration %s (children %d)\n", indent, sanitizeUTF8(col.Name), children)
354354
} else {
355-
fmt.Fprintf(sb, "%sColumnDeclaration %s\n", indent, col.Name)
355+
fmt.Fprintf(sb, "%sColumnDeclaration %s\n", indent, sanitizeUTF8(col.Name))
356356
}
357357
if col.Type != nil {
358358
Node(sb, col.Type, depth+1)

internal/explain/expressions.go

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,48 @@ import (
44
"fmt"
55
"strconv"
66
"strings"
7+
"unicode/utf8"
78

89
"github.com/sqlc-dev/doubleclick/ast"
910
)
1011

12+
// sanitizeUTF8 replaces invalid UTF-8 bytes with the Unicode replacement character (U+FFFD)
13+
// and null bytes with the escape sequence \0.
14+
// This matches ClickHouse's behavior of displaying special bytes in EXPLAIN AST output.
15+
func sanitizeUTF8(s string) string {
16+
// Check if we need to process at all
17+
needsProcessing := !utf8.ValidString(s)
18+
if !needsProcessing {
19+
for i := 0; i < len(s); i++ {
20+
if s[i] == 0 {
21+
needsProcessing = true
22+
break
23+
}
24+
}
25+
}
26+
if !needsProcessing {
27+
return s
28+
}
29+
30+
var result strings.Builder
31+
for i := 0; i < len(s); {
32+
r, size := utf8.DecodeRuneInString(s[i:])
33+
if r == utf8.RuneError && size == 1 {
34+
// Invalid byte - write replacement character
35+
result.WriteRune('\uFFFD')
36+
i++
37+
} else if r == 0 {
38+
// Null byte - write as escape sequence \0
39+
result.WriteString("\\0")
40+
i += size
41+
} else {
42+
result.WriteRune(r)
43+
i += size
44+
}
45+
}
46+
return result.String()
47+
}
48+
1149
// escapeAlias escapes backslashes and single quotes in alias names for EXPLAIN output
1250
func escapeAlias(alias string) string {
1351
// Escape backslashes first, then single quotes
@@ -25,21 +63,31 @@ func explainIdentifier(sb *strings.Builder, n *ast.Identifier, indent string) {
2563
}
2664
}
2765

28-
// formatIdentifierName formats an identifier name, handling JSON path notation
66+
// escapeIdentifierPart escapes backslashes and single quotes in an identifier part
67+
// and sanitizes invalid UTF-8 bytes
68+
func escapeIdentifierPart(s string) string {
69+
s = sanitizeUTF8(s)
70+
s = strings.ReplaceAll(s, "\\", "\\\\")
71+
s = strings.ReplaceAll(s, "'", "\\'")
72+
return s
73+
}
74+
75+
// formatIdentifierName formats an identifier name, handling JSON path notation,
76+
// sanitizing invalid UTF-8 bytes, and escaping special characters
2977
func formatIdentifierName(n *ast.Identifier) string {
3078
if len(n.Parts) == 0 {
3179
return ""
3280
}
3381
if len(n.Parts) == 1 {
34-
return n.Parts[0]
82+
return escapeIdentifierPart(n.Parts[0])
3583
}
36-
result := n.Parts[0]
84+
result := escapeIdentifierPart(n.Parts[0])
3785
for _, p := range n.Parts[1:] {
3886
// JSON path notation: ^fieldname should be formatted as ^`fieldname`
3987
if strings.HasPrefix(p, "^") {
40-
result += ".^`" + p[1:] + "`"
88+
result += ".^`" + escapeIdentifierPart(p[1:]) + "`"
4189
} else {
42-
result += "." + p
90+
result += "." + escapeIdentifierPart(p)
4391
}
4492
}
4593
return result

internal/explain/functions.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import (
77
"github.com/sqlc-dev/doubleclick/ast"
88
)
99

10-
// escapeFunctionAlias escapes single quotes in function alias names.
11-
// Unlike escapeAlias (for column aliases), this does NOT escape backslashes
12-
// since ClickHouse EXPLAIN AST preserves backslashes in function aliases.
10+
// escapeFunctionAlias escapes backslashes and single quotes in function alias names.
11+
// This is needed because the lexer processes escape sequences in backtick identifiers.
1312
func escapeFunctionAlias(alias string) string {
14-
return strings.ReplaceAll(alias, "'", "\\'")
13+
result := strings.ReplaceAll(alias, "\\", "\\\\")
14+
return strings.ReplaceAll(result, "'", "\\'")
1515
}
1616

1717
// normalizeIntervalUnit converts interval units to title-cased singular form

internal/explain/statements.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,9 @@ func explainCreateQuery(sb *strings.Builder, n *ast.CreateQuery, indent string,
460460
// When ORDER BY has modifiers (ASC/DESC), wrap in StorageOrderByElement
461461
if n.OrderByHasModifiers {
462462
fmt.Fprintf(sb, "%s StorageOrderByElement (children %d)\n", storageIndent, 1)
463-
fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, ident.Name())
463+
fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, sanitizeUTF8(ident.Name()))
464464
} else {
465-
fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, ident.Name())
465+
fmt.Fprintf(sb, "%s Identifier %s\n", storageIndent, sanitizeUTF8(ident.Name()))
466466
}
467467
} else if lit, ok := n.OrderBy[0].(*ast.Literal); ok && lit.Type == ast.LiteralTuple {
468468
// Handle tuple literal - for ORDER BY with modifiers (DESC/ASC),

lexer/lexer.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,63 @@ func (l *Lexer) readBacktickIdentifier() Item {
698698
l.readChar() // skip closing backtick
699699
break
700700
}
701+
if l.ch == '\\' {
702+
l.readChar() // consume backslash
703+
if l.eof {
704+
break
705+
}
706+
// Interpret escape sequence (same as readString)
707+
switch l.ch {
708+
case '\'':
709+
sb.WriteRune('\'')
710+
case '"':
711+
sb.WriteRune('"')
712+
case '\\':
713+
sb.WriteRune('\\')
714+
case '`':
715+
sb.WriteRune('`')
716+
case 'n':
717+
sb.WriteRune('\n')
718+
case 't':
719+
sb.WriteRune('\t')
720+
case 'r':
721+
sb.WriteRune('\r')
722+
case '0':
723+
sb.WriteRune('\x00')
724+
case 'a':
725+
sb.WriteRune('\a')
726+
case 'b':
727+
sb.WriteRune('\b')
728+
case 'f':
729+
sb.WriteRune('\f')
730+
case 'v':
731+
sb.WriteRune('\v')
732+
case 'e':
733+
sb.WriteRune('\x1b') // escape character (ASCII 27)
734+
case 'x':
735+
// Hex escape: \xNN
736+
l.readChar()
737+
if l.eof {
738+
break
739+
}
740+
hex1 := l.ch
741+
l.readChar()
742+
if l.eof {
743+
sb.WriteRune(rune(hexValue(hex1)))
744+
continue
745+
}
746+
hex2 := l.ch
747+
// Convert hex digits to byte
748+
val := hexValue(hex1)*16 + hexValue(hex2)
749+
sb.WriteByte(byte(val))
750+
default:
751+
// Unknown escape, preserve both the backslash and the character
752+
sb.WriteRune('\\')
753+
sb.WriteRune(l.ch)
754+
}
755+
l.readChar()
756+
continue
757+
}
701758
sb.WriteRune(l.ch)
702759
l.readChar()
703760
}
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
1-
{
2-
"explain_todo": {
3-
"stmt2": true
4-
}
5-
}
1+
{}

0 commit comments

Comments
 (0)