blob: 5c30791b28caa71de8daf2838a0b1ecb9f215f72 [file] [log] [blame]
/*
Copyright 2019 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package spansql
import (
"fmt"
"math"
"reflect"
"testing"
)
func TestParseQuery(t *testing.T) {
tests := []struct {
in string
want Query
}{
{`SELECT 17`, Query{Select: Select{List: []Expr{IntegerLiteral(17)}}}},
{`SELECT Alias AS aka FROM Characters WHERE Age < @ageLimit AND Alias IS NOT NULL ORDER BY Age DESC LIMIT @limit` + "\n\t",
Query{
Select: Select{
List: []Expr{ID("Alias")},
From: []SelectFrom{{
Table: "Characters",
}},
Where: LogicalOp{
Op: And,
LHS: ComparisonOp{
LHS: ID("Age"),
Op: Lt,
RHS: Param("ageLimit"),
},
RHS: IsOp{
LHS: ID("Alias"),
Neg: true,
RHS: Null,
},
},
ListAliases: []string{"aka"},
},
Order: []Order{{
Expr: ID("Age"),
Desc: true,
}},
Limit: Param("limit"),
},
},
{`SELECT COUNT(*) FROM Packages`,
Query{
Select: Select{
List: []Expr{
Func{
Name: "COUNT",
Args: []Expr{Star},
},
},
From: []SelectFrom{{Table: "Packages"}},
},
},
},
{`SELECT * FROM Packages`,
Query{
Select: Select{
List: []Expr{Star},
From: []SelectFrom{{Table: "Packages"}},
},
},
},
{`SELECT SUM(PointsScored) AS total_points, FirstName, LastName AS surname FROM PlayerStats GROUP BY FirstName, LastName`,
Query{
Select: Select{
List: []Expr{
Func{Name: "SUM", Args: []Expr{ID("PointsScored")}},
ID("FirstName"),
ID("LastName"),
},
From: []SelectFrom{{Table: "PlayerStats"}},
GroupBy: []Expr{ID("FirstName"), ID("LastName")},
ListAliases: []string{"total_points", "", "surname"},
},
},
},
}
for _, test := range tests {
got, err := ParseQuery(test.in)
if err != nil {
t.Errorf("ParseQuery(%q): %v", test.in, err)
continue
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("ParseQuery(%q) incorrect.\n got %#v\nwant %#v", test.in, got, test.want)
}
}
}
func TestParseExpr(t *testing.T) {
tests := []struct {
in string
want Expr
}{
{`17`, IntegerLiteral(17)},
{`-1`, IntegerLiteral(-1)},
{fmt.Sprintf(`%d`, math.MaxInt64), IntegerLiteral(math.MaxInt64)},
{fmt.Sprintf(`%d`, math.MinInt64), IntegerLiteral(math.MinInt64)},
{"1.797693134862315708145274237317043567981e+308", FloatLiteral(math.MaxFloat64)},
{`4.940656458412465441765687928682213723651e-324`, FloatLiteral(math.SmallestNonzeroFloat64)},
{`0xf00d`, IntegerLiteral(0xf00d)},
{`-0xbeef`, IntegerLiteral(-0xbeef)},
{`0XabCD`, IntegerLiteral(0xabcd)},
{`-0XBEEF`, IntegerLiteral(-0xbeef)},
{`123.456e-67`, FloatLiteral(123.456e-67)},
{`-123.456e-67`, FloatLiteral(-123.456e-67)},
{`.1E4`, FloatLiteral(0.1e4)},
{`58.`, FloatLiteral(58)},
{`4e2`, FloatLiteral(4e2)},
{`X + Y * Z`, ArithOp{LHS: ID("X"), Op: Add, RHS: ArithOp{LHS: ID("Y"), Op: Mul, RHS: ID("Z")}}},
{`X + Y + Z`, ArithOp{LHS: ArithOp{LHS: ID("X"), Op: Add, RHS: ID("Y")}, Op: Add, RHS: ID("Z")}},
{`X * -Y`, ArithOp{LHS: ID("X"), Op: Mul, RHS: ArithOp{Op: Neg, RHS: ID("Y")}}},
{`ID&0x3fff`, ArithOp{LHS: ID("ID"), Op: BitAnd, RHS: IntegerLiteral(0x3fff)}},
{`SHA1("Hello" || " " || "World")`, Func{Name: "SHA1", Args: []Expr{ArithOp{LHS: ArithOp{LHS: StringLiteral("Hello"), Op: Concat, RHS: StringLiteral(" ")}, Op: Concat, RHS: StringLiteral("World")}}}},
{`Count > 0`, ComparisonOp{LHS: ID("Count"), Op: Gt, RHS: IntegerLiteral(0)}},
{`Name LIKE "Eve %"`, ComparisonOp{LHS: ID("Name"), Op: Like, RHS: StringLiteral("Eve %")}},
{`Speech NOT LIKE "_oo"`, ComparisonOp{LHS: ID("Speech"), Op: NotLike, RHS: StringLiteral("_oo")}},
{`A AND NOT B`, LogicalOp{LHS: ID("A"), Op: And, RHS: LogicalOp{Op: Not, RHS: ID("B")}}},
{`X BETWEEN Y AND Z`, ComparisonOp{LHS: ID("X"), Op: Between, RHS: ID("Y"), RHS2: ID("Z")}},
// String literal:
// Accept double quote and single quote.
{`"hello"`, StringLiteral("hello")},
{`'hello'`, StringLiteral("hello")},
// Accept triple-quote.
{`""" "hello" "world" """`, StringLiteral(` "hello" "world" `)},
{"''' 'hello'\n'world' '''", StringLiteral(" 'hello'\n'world' ")},
// Simple escape sequence
{`"\a\b\f\n\r\t\v\\\?\"\'"`, StringLiteral("\a\b\f\n\r\t\v\\?\"'")},
{`'\a\b\f\n\r\t\v\\\?\"\''`, StringLiteral("\a\b\f\n\r\t\v\\?\"'")},
{"'\\`'", StringLiteral("`")},
// Hex and unicode escape sequence
{`"\060\x30\X30\u0030\U00000030"`, StringLiteral("00000")},
{`'\060\x30\X30\u0030\U00000030'`, StringLiteral("00000")},
{`"\uBEAF\ubeaf"`, StringLiteral("\ubeaf\ubeaf")},
{`'\uBEAF\ubeaf'`, StringLiteral("\ubeaf\ubeaf")},
// Escape sequence in triple quote is allowed.
{`"""\u0030"""`, StringLiteral("0")},
{`'''\u0030'''`, StringLiteral("0")},
// Raw string literal
{`R"\\"`, StringLiteral("\\\\")},
{`R'\\'`, StringLiteral("\\\\")},
{`r"\\"`, StringLiteral("\\\\")},
{`r'\\'`, StringLiteral("\\\\")},
{`R"\\\""`, StringLiteral("\\\\\\\"")},
{`R"""\\//\\//"""`, StringLiteral("\\\\//\\\\//")},
{"R'''\\\\//\n\\\\//'''", StringLiteral("\\\\//\n\\\\//")},
// Bytes literal:
{`B"hello"`, BytesLiteral("hello")},
{`B'hello'`, BytesLiteral("hello")},
{`b"hello"`, BytesLiteral("hello")},
{`b'hello'`, BytesLiteral("hello")},
{`B""" "hello" "world" """`, BytesLiteral(` "hello" "world" `)},
{`B''' 'hello' 'world' '''`, BytesLiteral(` 'hello' 'world' `)},
{`B"\a\b\f\n\r\t\v\\\?\"\'"`, BytesLiteral("\a\b\f\n\r\t\v\\?\"'")},
{`B'\a\b\f\n\r\t\v\\\?\"\''`, BytesLiteral("\a\b\f\n\r\t\v\\?\"'")},
{"B'''\n'''", BytesLiteral("\n")},
{`br"\\"`, BytesLiteral("\\\\")},
{`br'\\'`, BytesLiteral("\\\\")},
{`rb"\\"`, BytesLiteral("\\\\")},
{`rb'\\'`, BytesLiteral("\\\\")},
{`RB"\\"`, BytesLiteral("\\\\")},
{`RB'\\'`, BytesLiteral("\\\\")},
{`BR"\\"`, BytesLiteral("\\\\")},
{`BR'\\'`, BytesLiteral("\\\\")},
{`RB"""\\//\\//"""`, BytesLiteral("\\\\//\\\\//")},
{"RB'''\\\\//\n\\\\//'''", BytesLiteral("\\\\//\n\\\\//")},
// OR is lower precedence than AND.
{`A AND B OR C`, LogicalOp{LHS: LogicalOp{LHS: ID("A"), Op: And, RHS: ID("B")}, Op: Or, RHS: ID("C")}},
{`A OR B AND C`, LogicalOp{LHS: ID("A"), Op: Or, RHS: LogicalOp{LHS: ID("B"), Op: And, RHS: ID("C")}}},
// Parens to override normal precedence.
{`A OR (B AND C)`, LogicalOp{LHS: ID("A"), Op: Or, RHS: Paren{Expr: LogicalOp{LHS: ID("B"), Op: And, RHS: ID("C")}}}},
// This is the same as the WHERE clause from the test in ParseQuery.
{`Age < @ageLimit AND Alias IS NOT NULL`,
LogicalOp{
LHS: ComparisonOp{LHS: ID("Age"), Op: Lt, RHS: Param("ageLimit")},
Op: And,
RHS: IsOp{LHS: ID("Alias"), Neg: true, RHS: Null},
},
},
// This used to be broken because the lexer didn't reset the token type.
{`C < "whelp" AND D IS NOT NULL`,
LogicalOp{
LHS: ComparisonOp{LHS: ID("C"), Op: Lt, RHS: StringLiteral("whelp")},
Op: And,
RHS: IsOp{LHS: ID("D"), Neg: true, RHS: Null},
},
},
// Reserved keywords.
{`TRUE AND FALSE`, LogicalOp{LHS: True, Op: And, RHS: False}},
{`NULL`, Null},
}
for _, test := range tests {
p := newParser("test-file", test.in)
got, err := p.parseExpr()
if err != nil {
t.Errorf("[%s]: %v", test.in, err)
continue
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("[%s]: incorrect parse\n got <%T> %#v\nwant <%T> %#v", test.in, got, got, test.want, test.want)
}
if p.s != "" {
t.Errorf("[%s]: Unparsed [%s]", test.in, p.s)
}
}
}
func TestParseDDL(t *testing.T) {
line := func(n int) Position { return Position{Line: n} }
tests := []struct {
in string
want *DDL
}{
{`CREATE TABLE FooBar (
System STRING(MAX) NOT NULL, # This is a comment.
RepoPath STRING(MAX) NOT NULL, -- This is another comment.
Count INT64, /* This is a
* multiline comment. */
UpdatedAt TIMESTAMP OPTIONS (allow_commit_timestamp = true),
) PRIMARY KEY(System, RepoPath);
CREATE UNIQUE INDEX MyFirstIndex ON FooBar (
Count DESC
) STORING (Count), INTERLEAVE IN SomeTable;
CREATE TABLE FooBarAux (
System STRING(MAX) NOT NULL,
CONSTRAINT Con1 FOREIGN KEY (System) REFERENCES FooBar (System),
RepoPath STRING(MAX) NOT NULL,
FOREIGN KEY (System, RepoPath) REFERENCES Stranger (Sys, RPath), -- unnamed foreign key
Author STRING(MAX) NOT NULL,
) PRIMARY KEY(System, RepoPath, Author),
INTERLEAVE IN PARENT FooBar ON DELETE CASCADE;
ALTER TABLE FooBar ADD COLUMN TZ BYTES(20);
ALTER TABLE FooBar DROP COLUMN TZ;
ALTER TABLE FooBar SET ON DELETE NO ACTION;
ALTER TABLE FooBar ALTER COLUMN Author STRING(MAX) NOT NULL;
DROP INDEX MyFirstIndex;
DROP TABLE FooBar;
-- This table has some commentary
-- that spans multiple lines.
CREATE TABLE NonScalars (
Dummy INT64 NOT NULL, -- dummy comment
Ids ARRAY<INT64>, -- comment on ids
Names ARRAY<STRING(MAX)>,
) PRIMARY KEY (Dummy);
`, &DDL{Filename: "filename", List: []DDLStmt{
&CreateTable{
Name: "FooBar",
Columns: []ColumnDef{
{Name: "System", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(2)},
{Name: "RepoPath", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(3)},
{Name: "Count", Type: Type{Base: Int64}, Position: line(4)},
{Name: "UpdatedAt", Type: Type{Base: Timestamp}, AllowCommitTimestamp: boolAddr(true), Position: line(6)},
},
PrimaryKey: []KeyPart{
{Column: "System"},
{Column: "RepoPath"},
},
Position: line(1),
},
&CreateIndex{
Name: "MyFirstIndex",
Table: "FooBar",
Columns: []KeyPart{{Column: "Count", Desc: true}},
Unique: true,
Storing: []string{"Count"},
Interleave: "SomeTable",
Position: line(8),
},
&CreateTable{
Name: "FooBarAux",
Columns: []ColumnDef{
{Name: "System", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(12)},
{Name: "RepoPath", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(14)},
{Name: "Author", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(16)},
},
Constraints: []TableConstraint{
{
Name: "Con1",
ForeignKey: ForeignKey{
Columns: []string{"System"},
RefTable: "FooBar",
RefColumns: []string{"System"},
Position: line(13),
},
Position: line(13),
},
{
ForeignKey: ForeignKey{
Columns: []string{"System", "RepoPath"},
RefTable: "Stranger",
RefColumns: []string{"Sys", "RPath"},
Position: line(15),
},
Position: line(15),
},
},
PrimaryKey: []KeyPart{
{Column: "System"},
{Column: "RepoPath"},
{Column: "Author"},
},
Interleave: &Interleave{
Parent: "FooBar",
OnDelete: CascadeOnDelete,
},
Position: line(11),
},
&AlterTable{
Name: "FooBar",
Alteration: AddColumn{Def: ColumnDef{Name: "TZ", Type: Type{Base: Bytes, Len: 20}, Position: line(20)}},
Position: line(20),
},
&AlterTable{
Name: "FooBar",
Alteration: DropColumn{Name: "TZ"},
Position: line(21),
},
&AlterTable{
Name: "FooBar",
Alteration: SetOnDelete{Action: NoActionOnDelete},
Position: line(22),
},
&AlterTable{
Name: "FooBar",
Alteration: AlterColumn{Def: ColumnDef{
Name: "Author",
Type: Type{Base: String, Len: MaxLen},
NotNull: true,
Position: line(23),
}},
Position: line(23),
},
&DropIndex{Name: "MyFirstIndex", Position: line(25)},
&DropTable{Name: "FooBar", Position: line(26)},
&CreateTable{
Name: "NonScalars",
Columns: []ColumnDef{
{Name: "Dummy", Type: Type{Base: Int64}, NotNull: true, Position: line(31)},
{Name: "Ids", Type: Type{Array: true, Base: Int64}, Position: line(32)},
{Name: "Names", Type: Type{Array: true, Base: String, Len: MaxLen}, Position: line(33)},
},
PrimaryKey: []KeyPart{{Column: "Dummy"}},
Position: line(30),
},
}, Comments: []*Comment{
{Marker: "#", Start: line(2), End: line(2),
Text: []string{"This is a comment."}},
{Marker: "--", Start: line(3), End: line(3),
Text: []string{"This is another comment."}},
{Marker: "/*", Start: line(4), End: line(5),
Text: []string{" This is a", "\t\t\t\t\t\t * multiline comment."}},
{Marker: "--", Start: line(15), End: line(15),
Text: []string{"unnamed foreign key"}},
{Marker: "--", Isolated: true, Start: line(28), End: line(29),
Text: []string{"This table has some commentary", "that spans multiple lines."}},
// These comments shouldn't get combined:
{Marker: "--", Start: line(31), End: line(31), Text: []string{"dummy comment"}},
{Marker: "--", Start: line(32), End: line(32), Text: []string{"comment on ids"}},
}}},
// No trailing comma:
{`ALTER TABLE T ADD COLUMN C2 INT64`, &DDL{Filename: "filename", List: []DDLStmt{
&AlterTable{
Name: "T",
Alteration: AddColumn{Def: ColumnDef{Name: "C2", Type: Type{Base: Int64}, Position: line(1)}},
Position: line(1),
},
}}},
// Table and column names using reserved keywords.
{`CREATE TABLE ` + "`enum`" + ` (
` + "`With`" + ` STRING(MAX) NOT NULL,
) PRIMARY KEY(` + "`With`" + `);
`, &DDL{Filename: "filename", List: []DDLStmt{
&CreateTable{
Name: "enum",
Columns: []ColumnDef{
{Name: "With", Type: Type{Base: String, Len: MaxLen}, NotNull: true, Position: line(2)},
},
PrimaryKey: []KeyPart{
{Column: "With"},
},
Position: line(1),
},
}}},
}
for _, test := range tests {
got, err := ParseDDL("filename", test.in)
if err != nil {
t.Errorf("ParseDDL(%q): %v", test.in, err)
continue
}
got.clearOffset()
if !reflect.DeepEqual(got, test.want) {
t.Errorf("ParseDDL(%q) incorrect.\n got %v\nwant %v", test.in, got, test.want)
// Also log the specific elements that don't match to make it easier to debug
// especially the large DDLs.
for i := range got.List {
if !reflect.DeepEqual(got.List[i], test.want.List[i]) {
t.Errorf("\tstatement %d mismatch:\n\t got %v\n\twant %v", i, got.List[i], test.want.List[i])
}
}
for i := range got.Comments {
if !reflect.DeepEqual(got.Comments[i], test.want.Comments[i]) {
t.Errorf("\tcomment %d mismatch:\n\t got %v\n\twant %v", i, got.Comments[i], test.want.Comments[i])
}
}
}
}
// Check the comment discovey helpers on the first DDL.
// Reparse it first so we get full position information.
ddl, err := ParseDDL("filename", tests[0].in)
if err != nil {
t.Fatal(err)
}
// The CreateTable for NonScalars has a leading comment.
com := ddl.LeadingComment(tableByName(t, ddl, "NonScalars"))
if com == nil {
t.Errorf("No leading comment found for NonScalars")
} else if com.Text[0] != "This table has some commentary" {
t.Errorf("LeadingComment returned the wrong comment for NonScalars")
}
// Second field of FooBar (RepoPath) has an inline comment.
cd := tableByName(t, ddl, "FooBar").Columns[1]
if com := ddl.InlineComment(cd); com == nil {
t.Errorf("No inline comment found for FooBar.RepoPath")
} else if com.Text[0] != "This is another comment." {
t.Errorf("InlineComment returned the wrong comment (%q) for FooBar.RepoPath", com.Text[0])
}
// There are no leading comments on the columns of NonScalars,
// even though there's often a comment on the previous line.
for _, cd := range tableByName(t, ddl, "NonScalars").Columns {
if com := ddl.LeadingComment(cd); com != nil {
t.Errorf("Leading comment found for NonScalars.%s: %v", cd.Name, com)
}
}
}
func tableByName(t *testing.T, ddl *DDL, name string) *CreateTable {
t.Helper()
for _, stmt := range ddl.List {
if ct, ok := stmt.(*CreateTable); ok && ct.Name == name {
return ct
}
}
t.Fatalf("no table with name %q", name)
panic("unreachable")
}
func TestParseFailures(t *testing.T) {
expr := func(p *parser) error {
_, err := p.parseExpr()
return err
}
tests := []struct {
f func(p *parser) error
in string
desc string
}{
{expr, `0b337`, "binary literal"},
{expr, `"foo\`, "unterminated string"},
{expr, `"\i"`, "invalid escape sequence"},
{expr, `"\0"`, "invalid escape sequence"},
{expr, `"\099"`, "invalid escape sequence"},
{expr, `"\400"`, "invalid escape sequence: octal digits overflow"},
{expr, `"\x"`, "invalid escape sequence"},
{expr, `"\xFZ"`, "invalid escape sequence"},
{expr, `"\u"`, "invalid escape sequence"},
{expr, `"\uFFFZ"`, "invalid escape sequence"},
{expr, `"\uD800"`, "invalid unicode character (surrogate)"},
{expr, `"\U"`, "invalid escape sequence"},
{expr, `"\UFFFFFFFZ"`, "invalid escape sequence"},
{expr, `"\U00110000"`, "invalid unicode character (out of range)"},
{expr, "\"\n\"", "unterminated string by newline (double quote)"},
{expr, "'\n'", "unterminated string by newline (single quote)"},
{expr, "R\"\n\"", "unterminated raw string by newline (double quote)"},
{expr, "R'\n'", "unterminated raw string by newline (single quote)"},
{expr, `B"\u0030"`, "\\uXXXX sequence is not supported in bytes literal (double quote)"},
{expr, `B'\u0030'`, "\\uXXXX sequence is not supported in bytes literal (double quote)"},
{expr, `B"\U00000030"`, "\\UXXXXXXXX sequence is not supported in bytes literal (double quote)"},
{expr, `B'\U00000030'`, "\\UXXXXXXXX sequence is not supported in bytes literal (double quote)"},
{expr, `BB""`, "invalid string-like literal prefix"},
{expr, `rr""`, "invalid string-like literal prefix"},
{expr, `"""\"""`, "unterminated triple-quoted string by last backslash (double quote)"},
{expr, `'''\'''`, "unterminated triple-quoted string by last backslash (single quote)"},
{expr, `"foo" AND "bar"`, "logical operation on string literals"},
}
for _, test := range tests {
p := newParser("f", test.in)
err := test.f(p)
if err == nil && p.Rem() == "" {
t.Errorf("%s: parsing [%s] succeeded, should have failed", test.desc, test.in)
}
}
}