diff --git a/README.md b/README.md index a9e0c24..6254ee2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dbms_test.go b/dbms_test.go index c66368d..c5c77f5 100644 --- a/dbms_test.go +++ b/dbms_test.go @@ -39,6 +39,7 @@ func TestQueriesPerDBMS(t *testing.T) { DBMSSQLServer, DBMSMySQL, DBMSSnowflake, + DBMSCassandra, } for _, dbms := range dbmsTypes { diff --git a/normalizer_test.go b/normalizer_test.go index d6d842a..c00d688 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -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( diff --git a/obfuscator.go b/obfuscator.go index 4c00947..bf01e58 100644 --- a/obfuscator.go +++ b/obfuscator.go @@ -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 } diff --git a/obfuscator_test.go b/obfuscator_test.go index 7dd2d2a..4cd61d4 100644 --- a/obfuscator_test.go +++ b/obfuscator_test.go @@ -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 = ?", diff --git a/sqllexer.go b/sqllexer.go index f4bd298..d7fe1eb 100644 --- a/sqllexer.go +++ b/sqllexer.go @@ -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. @@ -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 @@ -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() @@ -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) @@ -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 diff --git a/sqllexer_fuzz_test.go b/sqllexer_fuzz_test.go index 3534e51..12efc37 100644 --- a/sqllexer_fuzz_test.go +++ b/sqllexer_fuzz_test.go @@ -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 @@ -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 @@ -189,6 +200,7 @@ func addComplexTestCases(f *testing.F) { string(DBMSMySQL), string(DBMSOracle), string(DBMSSnowflake), + string(DBMSCassandra), } for _, pattern := range patterns { @@ -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 @@ -331,6 +351,11 @@ 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), @@ -338,6 +363,7 @@ func addObfuscationTestCases(f *testing.F) { string(DBMSMySQL), string(DBMSOracle), string(DBMSSnowflake), + string(DBMSCassandra), } for _, pattern := range append(commonPatterns, quoteEdgeCases...) { diff --git a/sqllexer_test.go b/sqllexer_test.go index b950a01..10c7967 100644 --- a/sqllexer_test.go +++ b/sqllexer_test.go @@ -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 { diff --git a/sqllexer_utils.go b/sqllexer_utils.go index bdbca25..899c0eb 100644 --- a/sqllexer_utils.go +++ b/sqllexer_utils.go @@ -21,12 +21,17 @@ const ( DBMSOracle DBMSType = "oracle" // DBMSSnowflake is a Snowflake Server DBMSSnowflake DBMSType = "snowflake" + // DBMSCassandra is a Cassandra/CQL Server + DBMSCassandra DBMSType = "cassandra" + // DBMSCQL is an alias for Cassandra used by some tracers + DBMSCQL DBMSType = "cql" ) var dbmsAliases = map[DBMSType]DBMSType{ DBMSSQLServerAlias1: DBMSSQLServer, DBMSSQLServerAlias2: DBMSSQLServer, DBMSPostgresAlias1: DBMSPostgres, + DBMSCQL: DBMSCassandra, } func getDBMSFromAlias(alias DBMSType) DBMSType { @@ -58,6 +63,7 @@ var commands = []string{ "USE", "CLONE", "VACUUM", + "BATCH", // Cassandra/CQL } var tableIndicatorCommands = []string{ @@ -154,6 +160,18 @@ var keywords = []string{ "SKIP", "IF", "ONLY", + // Cassandra/CQL + "ALLOW", + "FILTERING", + "KEYSPACE", + "TOKEN", + "TTL", + "WRITETIME", + "PARTITION", + "COMPACT", + "STORAGE", + "FROZEN", + "STATIC", } var ( @@ -371,6 +389,16 @@ func isDigit(ch rune) bool { return ch >= '0' && ch <= '9' } +// isHexDigit checks if a rune is a hexadecimal digit (0-9, a-f, A-F) +func isHexDigit(ch rune) bool { + return isDigit(ch) || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') +} + +// isHexByte checks if a byte is a hexadecimal digit (0-9, a-f, A-F) +func isHexByte(b byte) bool { + return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F') +} + // isLeadingDigit checks if a rune is + or - func isLeadingSign(ch rune) bool { return ch == '+' || ch == '-' diff --git a/testdata/cassandra/batch/begin-batch.json b/testdata/cassandra/batch/begin-batch.json new file mode 100644 index 0000000..8d57303 --- /dev/null +++ b/testdata/cassandra/batch/begin-batch.json @@ -0,0 +1,22 @@ +{ + "input": "BEGIN BATCH INSERT INTO users (id, name) VALUES (550e8400-e29b-41d4-a716-446655440000, 'alice'); DELETE FROM users WHERE id = a50e8400-e29b-41d4-a716-446655440000 APPLY BATCH", + "outputs": [ + { + "expected": "BEGIN BATCH INSERT INTO users ( id, name ) VALUES ( ? ); DELETE FROM users WHERE id = ? APPLY BATCH", + "statement_metadata": { + "size": 27, + "tables": [ + "users" + ], + "commands": [ + "BEGIN", + "BATCH", + "INSERT", + "DELETE" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/delete/by-uuid.json b/testdata/cassandra/delete/by-uuid.json new file mode 100644 index 0000000..7fc247e --- /dev/null +++ b/testdata/cassandra/delete/by-uuid.json @@ -0,0 +1,19 @@ +{ + "input": "DELETE FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000", + "outputs": [ + { + "expected": "DELETE FROM users WHERE id = ?", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "DELETE" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/insert/cql-docs-uuid.json b/testdata/cassandra/insert/cql-docs-uuid.json new file mode 100644 index 0000000..35d172e --- /dev/null +++ b/testdata/cassandra/insert/cql-docs-uuid.json @@ -0,0 +1,19 @@ +{ + "input": "INSERT INTO eval.cql_docs (id, name, status) VALUES (550e8400-e29b-41d4-a716-446655440000, 'uuid-user', 'ACTIVE')", + "outputs": [ + { + "expected": "INSERT INTO eval.cql_docs ( id, name, status ) VALUES ( ? )", + "statement_metadata": { + "size": 19, + "tables": [ + "eval.cql_docs" + ], + "commands": [ + "INSERT" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/insert/uuid-and-blob.json b/testdata/cassandra/insert/uuid-and-blob.json new file mode 100644 index 0000000..9335a3b --- /dev/null +++ b/testdata/cassandra/insert/uuid-and-blob.json @@ -0,0 +1,19 @@ +{ + "input": "INSERT INTO users (id, data, name) VALUES (550e8400-e29b-41d4-a716-446655440000, 0xdeadbeef, 'alice')", + "outputs": [ + { + "expected": "INSERT INTO users ( id, data, name ) VALUES ( ? )", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "INSERT" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/select/named-bind.json b/testdata/cassandra/select/named-bind.json new file mode 100644 index 0000000..1692846 --- /dev/null +++ b/testdata/cassandra/select/named-bind.json @@ -0,0 +1,19 @@ +{ + "input": "SELECT * FROM users WHERE id = :user_id AND name = ?", + "outputs": [ + { + "expected": "SELECT * FROM users WHERE id = :user_id AND name = ?", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "SELECT" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/select/uuid-hex-prefix.json b/testdata/cassandra/select/uuid-hex-prefix.json new file mode 100644 index 0000000..5ba9b84 --- /dev/null +++ b/testdata/cassandra/select/uuid-hex-prefix.json @@ -0,0 +1,19 @@ +{ + "input": "SELECT * FROM users WHERE id = a50e8400-e29b-41d4-a716-446655440000 ALLOW FILTERING", + "outputs": [ + { + "expected": "SELECT * FROM users WHERE id = ? ALLOW FILTERING", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "SELECT" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/select/uuid-literal.json b/testdata/cassandra/select/uuid-literal.json new file mode 100644 index 0000000..1a210dc --- /dev/null +++ b/testdata/cassandra/select/uuid-literal.json @@ -0,0 +1,19 @@ +{ + "input": "SELECT * FROM users WHERE id = 550e8400-e29b-41d4-a716-446655440000", + "outputs": [ + { + "expected": "SELECT * FROM users WHERE id = ?", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "SELECT" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/cassandra/update/using-ttl.json b/testdata/cassandra/update/using-ttl.json new file mode 100644 index 0000000..b87af50 --- /dev/null +++ b/testdata/cassandra/update/using-ttl.json @@ -0,0 +1,19 @@ +{ + "input": "UPDATE users USING TTL 86400 SET name = 'bob' WHERE id = 550e8400-e29b-41d4-a716-446655440000", + "outputs": [ + { + "expected": "UPDATE users USING TTL ? SET name = ? WHERE id = ?", + "statement_metadata": { + "size": 11, + "tables": [ + "users" + ], + "commands": [ + "UPDATE" + ], + "comments": [], + "procedures": [] + } + } + ] +} diff --git a/testdata/mssql/complex/partitioned-tables-indexes.json b/testdata/mssql/complex/partitioned-tables-indexes.json index c06612e..e68fd5b 100644 --- a/testdata/mssql/complex/partitioned-tables-indexes.json +++ b/testdata/mssql/complex/partitioned-tables-indexes.json @@ -2,7 +2,7 @@ "input": "CREATE PARTITION FUNCTION myRangePF1 (INT) AS RANGE LEFT FOR VALUES (1, 100, 1000); CREATE PARTITION SCHEME myScheme AS PARTITION myRangePF1 TO ([PRIMARY], [SECONDARY], [TERTIARY]); CREATE TABLE partitionedTable (id INT) ON myScheme(id);", "outputs": [ { - "expected": "CREATE PARTITION FUNCTION myRangePF? ( INT ) LEFT FOR VALUES ( ? ); CREATE PARTITION SCHEME myScheme myRangePF? TO ( PRIMARY, SECONDARY, TERTIARY ); CREATE TABLE partitionedTable ( id INT ) ON myScheme ( id )", + "expected": "CREATE PARTITION FUNCTION myRangePF? ( INT ) LEFT FOR VALUES ( ? ); CREATE PARTITION SCHEME myScheme AS PARTITION myRangePF? TO ( PRIMARY, SECONDARY, TERTIARY ); CREATE TABLE partitionedTable ( id INT ) ON myScheme ( id )", "statement_metadata": { "size": 22, "tables": ["partitionedTable"],