Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Use the `-dbms` flag to specify the database type:
- `mysql` - MySQL
- `oracle` - Oracle
- `snowflake` - Snowflake
- `cassandra` / `cql` - Cassandra (CQL), including UUID literals

## Testing

Expand Down
1 change: 1 addition & 0 deletions dbms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestQueriesPerDBMS(t *testing.T) {
DBMSSQLServer,
DBMSMySQL,
DBMSSnowflake,
DBMSCassandra,
}

for _, dbms := range dbmsTypes {
Expand Down
11 changes: 11 additions & 0 deletions normalizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,17 @@ multiline comment */
Size: 6,
},
},
{
input: "SELECT * FROM eval.no_such_table_eval",
expected: "SELECT * FROM eval.no_such_table_eval",
statementMetadata: StatementMetadata{
Tables: []string{"eval.no_such_table_eval"},
Comments: []string{},
Commands: []string{"SELECT"},
Procedures: []string{},
Size: 29,
},
},
}

normalizer := NewNormalizer(
Expand Down
2 changes: 1 addition & 1 deletion obfuscator.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func (o *Obfuscator) ObfuscateTokenValue(token *Token, lastValueToken *LastValue
break
}
token.Value = StringPlaceholder
case STRING, INCOMPLETE_STRING, DOLLAR_QUOTED_STRING:
case STRING, INCOMPLETE_STRING, DOLLAR_QUOTED_STRING, UUID:
if o.config.KeepJsonPath && lastValueToken != nil && lastValueToken.Type == JSON_OP {
break
}
Expand Down
18 changes: 18 additions & 0 deletions obfuscator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ func TestObfuscator(t *testing.T) {
expected: `SELECT col FROM tbl WHERE col LIKE ? ESCAPE ?`,
dbms: DBMSSnowflake,
},
{
// Cassandra/CQL UUID literals are obfuscated as a single placeholder
input: `SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000 AND name = 'alice'`,
expected: `SELECT * FROM users WHERE id = ? AND name = ?`,
dbms: DBMSCassandra,
},
{
// Cassandra UUID starting with hex letter + blob literal
input: `INSERT INTO users (id, data) VALUES (a50e8400-e29b-41d4-a716-446655440000, 0xdeadbeef)`,
expected: `INSERT INTO users (id, data) VALUES (?, ?)`,
dbms: DBMSCassandra,
},
{
// cql alias should behave like cassandra
input: `SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000`,
expected: `SELECT * FROM users WHERE id = ?`,
dbms: DBMSCQL,
},
{
input: "SELECT * FROM \"users table\" where id = 1",
expected: "SELECT * FROM \"users table\" where id = ?",
Expand Down
50 changes: 49 additions & 1 deletion sqllexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const (
PROC_INDICATOR // procedure indicator
CTE_INDICATOR // CTE indicator
ALIAS_INDICATOR // alias indicator
UUID // UUID literal (Cassandra/CQL)
)

// Token represents a SQL token with its type and value.
Expand Down Expand Up @@ -109,6 +110,10 @@ func (s *Lexer) Scan() *Token {
case isSpace(ch):
return s.scanWhitespace()
case isLetter(ch):
// Cassandra/CQL UUIDs may start with a hex letter (a-f)
if s.config.DBMS == DBMSCassandra && isHexDigit(ch) && s.isUUID() {
return s.scanUUID()
}
return s.scanIdentifier(ch)
case isDoubleQuote(ch):
// MySQL by default (without ANSI_QUOTES mode) treats double quotes as string literals
Expand All @@ -131,6 +136,11 @@ func (s *Lexer) Scan() *Token {
}
return s.scanOperator(ch)
case isDigit(ch):
// Cassandra/CQL UUID literals must be recognized before number scanning,
// otherwise scientific notation (e.g. 550e8400) splits the UUID apart.
if s.config.DBMS == DBMSCassandra && s.isUUID() {
return s.scanUUID()
}
return s.scanNumber(ch)
case isWildcard(ch):
return s.scanWildcard()
Expand All @@ -144,7 +154,8 @@ func (s *Lexer) Scan() *Token {
}
return s.scanDollarQuotedString()
case ch == ':':
if s.config.DBMS == DBMSOracle && isAlphaNumeric(s.lookAhead(1)) {
// Oracle and Cassandra/CQL support named bind parameters (:name)
if (s.config.DBMS == DBMSOracle || s.config.DBMS == DBMSCassandra) && isAlphaNumeric(s.lookAhead(1)) {
return s.scanBindParameter()
}
return s.scanOperator(ch)
Expand Down Expand Up @@ -616,6 +627,43 @@ func (s *Lexer) scanPositionalParameter() *Token {
return s.emit(POSITIONAL_PARAMETER)
}

// uuidGroupLens is the standard UUID group lengths: 8-4-4-4-12 (36 chars with hyphens).
var uuidGroupLens = [...]int{8, 4, 4, 4, 12}

// isUUID reports whether the input starting at the cursor is a UUID literal
// of the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (hex digits).
func (s *Lexer) isUUID() bool {
pos := s.cursor
for i, groupLen := range uuidGroupLens {
if i > 0 {
if pos >= len(s.src) || s.src[pos] != '-' {
return false
}
pos++
}
for j := 0; j < groupLen; j++ {
if pos >= len(s.src) || !isHexByte(s.src[pos]) {
return false
}
pos++
}
}
// Reject if the UUID is a prefix of a longer identifier/literal
if pos < len(s.src) {
c := s.src[pos]
if isHexByte(c) || c == '-' || c == '_' {
return false
}
}
return true
}

func (s *Lexer) scanUUID() *Token {
s.start = s.cursor
s.nextBy(36) // UUID literals are always 36 ASCII characters
return s.emit(UUID)
}

func (s *Lexer) scanBindParameter() *Token {
s.start = s.cursor
ch := s.nextBy(2) // consume the (colon|at sign) and the char
Expand Down
26 changes: 26 additions & 0 deletions sqllexer_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ func addComplexTestCases(f *testing.F) {
`SELECT $1, $2 FROM @mystage/file.csv`,
}

// Cassandra/CQL specific patterns
cassandraPatterns := []string{
`SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000`,
`SELECT * FROM users WHERE id = a50e8400-e29b-41d4-a716-446655440000 ALLOW FILTERING`,
`INSERT INTO users (id, data) VALUES (550e8400-e29b-41d4-a716-446655440000, 0xabcdef)`,
`SELECT * FROM users WHERE id = :user_id`,
`BEGIN BATCH INSERT INTO users (id) VALUES (1); DELETE FROM users WHERE id = 2 APPLY BATCH`,
`SELECT WRITETIME(name), TTL(name) FROM users WHERE token(id) > 0`,
}

// Common edge cases across all DBMS
commonEdgeCases := []string{
// Nested subqueries
Expand Down Expand Up @@ -180,6 +190,7 @@ func addComplexTestCases(f *testing.F) {
patterns = append(patterns, mysqlPatterns...)
patterns = append(patterns, oraclePatterns...)
patterns = append(patterns, snowflakePatterns...)
patterns = append(patterns, cassandraPatterns...)
patterns = append(patterns, commonEdgeCases...)

// Add each pattern with different DBMS types
Expand All @@ -189,6 +200,7 @@ func addComplexTestCases(f *testing.F) {
string(DBMSMySQL),
string(DBMSOracle),
string(DBMSSnowflake),
string(DBMSCassandra),
}

for _, pattern := range patterns {
Expand Down Expand Up @@ -259,6 +271,14 @@ func addObfuscationTestCases(f *testing.F) {
`SELECT $1, $2, $3 FROM @mystage`,
}

// Cassandra/CQL specific obfuscation patterns
cassandraPatterns := []string{
`SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000`,
`SELECT * FROM users WHERE id = a50e8400-e29b-41d4-a716-446655440000`,
`INSERT INTO users (id, blob) VALUES (550e8400-e29b-41d4-a716-446655440000, 0xdeadbeef)`,
`SELECT * FROM users WHERE name = 'alice' ALLOW FILTERING`,
}

// Common obfuscation patterns for all DBMS
commonPatterns := []string{
// Basic numbers
Expand Down Expand Up @@ -331,13 +351,19 @@ func addObfuscationTestCases(f *testing.F) {
f.Add(pattern, string(DBMSSnowflake))
}

// Add Cassandra patterns with Cassandra DBMS
for _, pattern := range cassandraPatterns {
f.Add(pattern, string(DBMSCassandra))
}

// Add common patterns and quote edge cases with all DBMS types
dbmsTypes := []string{
string(DBMSPostgres),
string(DBMSSQLServer),
string(DBMSMySQL),
string(DBMSOracle),
string(DBMSSnowflake),
string(DBMSCassandra),
}

for _, pattern := range append(commonPatterns, quoteEdgeCases...) {
Expand Down
162 changes: 162 additions & 0 deletions sqllexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,168 @@ here */`,
{IDENT, "my_table"},
},
},
// Cassandra/CQL: UUID literal starting with a digit (would otherwise be
// mis-scanned as scientific notation: 550e8400)
{
name: "Cassandra UUID starting with digit",
input: "SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{WILDCARD, "*"},
{SPACE, " "},
{KEYWORD, "FROM"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{KEYWORD, "WHERE"},
{SPACE, " "},
{IDENT, "id"},
{SPACE, " "},
{OPERATOR, "="},
{SPACE, " "},
{UUID, "550e8400-e29b-41d4-a716-446655440000"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCassandra)},
},
{
name: "Cassandra UUID starting with hex letter",
input: "SELECT * FROM users WHERE id = a50e8400-e29b-41d4-a716-446655440000",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{WILDCARD, "*"},
{SPACE, " "},
{KEYWORD, "FROM"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{KEYWORD, "WHERE"},
{SPACE, " "},
{IDENT, "id"},
{SPACE, " "},
{OPERATOR, "="},
{SPACE, " "},
{UUID, "a50e8400-e29b-41d4-a716-446655440000"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCassandra)},
},
{
name: "Cassandra cql alias resolves to cassandra",
input: "SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{WILDCARD, "*"},
{SPACE, " "},
{KEYWORD, "FROM"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{KEYWORD, "WHERE"},
{SPACE, " "},
{IDENT, "id"},
{SPACE, " "},
{OPERATOR, "="},
{SPACE, " "},
{UUID, "550e8400-e29b-41d4-a716-446655440000"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCQL)},
},
{
name: "Cassandra named bind parameter",
input: "SELECT * FROM users WHERE id = :user_id",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{WILDCARD, "*"},
{SPACE, " "},
{KEYWORD, "FROM"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{KEYWORD, "WHERE"},
{SPACE, " "},
{IDENT, "id"},
{SPACE, " "},
{OPERATOR, "="},
{SPACE, " "},
{BIND_PARAMETER, ":user_id"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCassandra)},
},
{
name: "Cassandra ALLOW FILTERING and blob literal",
input: "SELECT * FROM users WHERE data = 0xabcdef ALLOW FILTERING",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{WILDCARD, "*"},
{SPACE, " "},
{KEYWORD, "FROM"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{KEYWORD, "WHERE"},
{SPACE, " "},
{IDENT, "data"},
{SPACE, " "},
{OPERATOR, "="},
{SPACE, " "},
{NUMBER, "0xabcdef"},
{SPACE, " "},
{KEYWORD, "ALLOW"},
{SPACE, " "},
{KEYWORD, "FILTERING"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCassandra)},
},
{
name: "Cassandra BATCH command",
input: "BEGIN BATCH INSERT INTO users (id) VALUES (1) APPLY BATCH",
expected: []TokenSpec{
{COMMAND, "BEGIN"},
{SPACE, " "},
{COMMAND, "BATCH"},
{SPACE, " "},
{COMMAND, "INSERT"},
{SPACE, " "},
{KEYWORD, "INTO"},
{SPACE, " "},
{IDENT, "users"},
{SPACE, " "},
{PUNCTUATION, "("},
{IDENT, "id"},
{PUNCTUATION, ")"},
{SPACE, " "},
{KEYWORD, "VALUES"},
{SPACE, " "},
{PUNCTUATION, "("},
{NUMBER, "1"},
{PUNCTUATION, ")"},
{SPACE, " "},
{IDENT, "APPLY"},
{SPACE, " "},
{COMMAND, "BATCH"},
},
lexerOpts: []lexerOption{WithDBMS(DBMSCassandra)},
},
{
name: "UUID not recognized outside Cassandra",
input: "SELECT 550e8400-e29b-41d4-a716-446655440000",
expected: []TokenSpec{
{COMMAND, "SELECT"},
{SPACE, " "},
{NUMBER, "550e8400"},
{OPERATOR, "-"},
{IDENT, "e29b"},
{NUMBER, "-41"},
{IDENT, "d4"},
{OPERATOR, "-"},
{IDENT, "a716"},
{NUMBER, "-446655440000"},
},
},
}

for _, tt := range tests {
Expand Down
Loading