Skip to content

Commit 623bd77

Browse files
committed
feat: auto-quote identifiers with reserved keywords
1 parent 1893049 commit 623bd77

8 files changed

Lines changed: 285 additions & 47 deletions

File tree

builder/ident.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@ import (
99

1010
// N writes the given name / identifier.
1111
//
12-
// It will validate the identifier when writing the query,
13-
// but it will not detect all invalid identifiers that are invalid in PostgreSQL (especially considering reserved keywords).
12+
// It will validate the identifier when writing the query.
13+
// Reserved PostgreSQL keywords are automatically quoted (e.g. "from" becomes `"from"`).
1414
func N(s string) IdentExp {
15-
exp := IdentExp{ident: strings.TrimSpace(s)}
15+
ident := strings.TrimSpace(s)
16+
exp := IdentExp{
17+
ident: ident,
18+
quotedIdent: quoteIdentifierIfKeyword(ident),
19+
}
1620
exp.Exp = exp // self-reference for base methods
1721
return exp
1822
}
1923

2024
type IdentExp struct {
2125
ExpBase
22-
ident string
26+
ident string
27+
quotedIdent string // pre-computed quoted identifier for performance
2328
}
2429

2530
func (i IdentExp) IsExp() {}
@@ -45,7 +50,7 @@ func (i IdentExp) WriteSQL(sb *SQLBuilder) {
4550
}
4651
}
4752

48-
sb.WriteString(i.ident)
53+
sb.WriteString(i.quotedIdent)
4954
}
5055

5156
var validIdentifierRegex = regexp.MustCompile(`(?ms)\A(` +

builder/ident_test.go

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package builder_test
22

33
import (
4-
"strings"
54
"testing"
65

76
"github.com/stretchr/testify/assert"
@@ -11,43 +10,104 @@ import (
1110
)
1211

1312
func TestN(t *testing.T) {
14-
validIdentifiers := []string{
15-
"column_name1",
16-
`"MyTable".name`,
17-
"public.\"MyTable\".*",
18-
`"My"."Table".name`,
19-
`"My""Quoted""Table".*`,
20-
"space_trimmed ",
21-
"táblá_ñámé",
22-
"öäüß_column",
23-
`U&"d\0061t\+000061"`,
24-
`U&"\0441\043B\043E\043D"`,
25-
`U&"d!0061t!+000061" UESCAPE '!'`,
26-
}
13+
tests := []struct {
14+
input string
15+
expectInvalid bool
16+
expected string
17+
}{
18+
// Regular identifiers (unchanged)
19+
{"column_name1", false, "column_name1"},
20+
{"users", false, "users"},
21+
{"táblá_ñámé", false, "táblá_ñámé"},
22+
{"öäüß_column", false, "öäüß_column"},
23+
{"space_trimmed ", false, "space_trimmed"},
2724

28-
invalidIdentifiers := []string{
29-
"1column_name",
30-
`"MyTable.name`,
31-
`My"Table.name`,
32-
}
25+
// Dotted paths without keywords (unchanged)
26+
{"public.users", false, "public.users"},
27+
{"schema.mytable.mycolumn", false, "schema.mytable.mycolumn"},
3328

34-
for _, id := range validIdentifiers {
35-
t.Run(id, func(t *testing.T) {
36-
q := qrb.N(id)
29+
// Asterisks (unchanged)
30+
{"*", false, "*"},
31+
{"mytable.*", false, "mytable.*"},
32+
{"public.mytable.*", false, "public.mytable.*"},
3733

38-
sql, _, err := qrb.Build(q).ToSQL()
39-
require.NoError(t, err)
40-
assert.Equal(t, strings.TrimSpace(id), sql)
41-
assert.Equal(t, strings.TrimSpace(id), q.Ident())
42-
})
34+
// Dotted paths with "table" and "column" keywords
35+
{"schema.table.column", false, `schema."table"."column"`},
36+
{"table.*", false, `"table".*`},
37+
{"public.table.*", false, `public."table".*`},
38+
39+
// Already quoted identifiers (unchanged)
40+
{`"MyTable".name`, false, `"MyTable".name`},
41+
{`public."MyTable".*`, false, `public."MyTable".*`},
42+
{`"My"."Table".name`, false, `"My"."Table".name`},
43+
{`"My""Quoted""Table".*`, false, `"My""Quoted""Table".*`},
44+
45+
// Unicode identifiers (unchanged)
46+
{`U&"d\0061t\+000061"`, false, `U&"d\0061t\+000061"`},
47+
{`U&"\0441\043B\043E\043D"`, false, `U&"\0441\043B\043E\043D"`},
48+
{`U&"d!0061t!+000061" UESCAPE '!'`, false, `U&"d!0061t!+000061" UESCAPE '!'`},
49+
50+
// Keywords - should be auto-quoted
51+
{"from", false, `"from"`},
52+
{"select", false, `"select"`},
53+
{"where", false, `"where"`},
54+
{"order", false, `"order"`},
55+
{"group", false, `"group"`},
56+
{"user", false, `"user"`},
57+
{"table", false, `"table"`},
58+
{"to", false, `"to"`},
59+
{"all", false, `"all"`},
60+
{"and", false, `"and"`},
61+
{"or", false, `"or"`},
62+
{"not", false, `"not"`},
63+
{"null", false, `"null"`},
64+
{"true", false, `"true"`},
65+
{"false", false, `"false"`},
66+
{"in", false, "in"}, // "in" is not a reserved keyword
67+
68+
// Keywords with different cases - should be auto-quoted
69+
{"FROM", false, `"FROM"`},
70+
{"Select", false, `"Select"`},
71+
{"WHERE", false, `"WHERE"`},
72+
{"User", false, `"User"`},
73+
74+
// Keywords in dotted paths - only keyword parts should be quoted
75+
{"mytable.from.id", false, `mytable."from".id`},
76+
{"schema.select.mycolumn", false, `schema."select".mycolumn`},
77+
{"public.user.name", false, `public."user".name`},
78+
{"from.to.where", false, `"from"."to"."where"`},
79+
{"table.from.id", false, `"table"."from".id`},
80+
{"schema.select.column", false, `schema."select"."column"`},
81+
82+
// Already quoted keywords (unchanged)
83+
{`"from"`, false, `"from"`},
84+
{`"select"`, false, `"select"`},
85+
{`mytable."from".id`, false, `mytable."from".id`},
86+
{`"table"."from"."id"`, false, `"table"."from"."id"`},
87+
88+
// Quoted identifier with dot inside (unchanged)
89+
{`schema."my.table".mycolumn`, false, `schema."my.table".mycolumn`},
90+
// Keywords in quoted identifier context
91+
{`table."from".id`, false, `"table"."from".id`},
92+
{`schema."my.table".column`, false, `schema."my.table"."column"`},
93+
94+
// Invalid identifiers
95+
{"1column_name", true, ""},
96+
{`"MyTable.name`, true, ""},
97+
{`My"Table.name`, true, ""},
4398
}
4499

45-
for _, id := range invalidIdentifiers {
46-
t.Run(id, func(t *testing.T) {
47-
q := qrb.N(id)
100+
for _, tt := range tests {
101+
t.Run(tt.input, func(t *testing.T) {
102+
q := qrb.N(tt.input)
48103

49-
_, _, err := qrb.Build(q).ToSQL()
50-
require.Error(t, err)
104+
sql, _, err := qrb.Build(q).ToSQL()
105+
if tt.expectInvalid {
106+
require.Error(t, err)
107+
} else {
108+
require.NoError(t, err)
109+
assert.Equal(t, tt.expected, sql)
110+
}
51111
})
52112
}
53113
}

builder/keywords.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package builder
2+
3+
import "strings"
4+
5+
// pgReservedKeywords contains PostgreSQL reserved keywords that must be quoted
6+
// when used as identifiers. Keywords are stored in uppercase for case-insensitive lookup.
7+
// Source: https://www.postgresql.org/docs/current/sql-keywords-appendix.html
8+
var pgReservedKeywords = map[string]struct{}{
9+
"ALL": {},
10+
"ANALYSE": {},
11+
"ANALYZE": {},
12+
"AND": {},
13+
"ANY": {},
14+
"ARRAY": {},
15+
"AS": {},
16+
"ASYMMETRIC": {},
17+
"BINARY": {},
18+
"BOTH": {},
19+
"CASE": {},
20+
"CAST": {},
21+
"CHECK": {},
22+
"COLLATE": {},
23+
"COLUMN": {},
24+
"CONCURRENTLY": {},
25+
"CONSTRAINT": {},
26+
"CREATE": {},
27+
"CROSS": {},
28+
"DEFAULT": {},
29+
"DEFERRABLE": {},
30+
"DESC": {},
31+
"DISTINCT": {},
32+
"DO": {},
33+
"ELSE": {},
34+
"END": {},
35+
"EXCEPT": {},
36+
"FALSE": {},
37+
"FETCH": {},
38+
"FOR": {},
39+
"FOREIGN": {},
40+
"FREEZE": {},
41+
"FROM": {},
42+
"FULL": {},
43+
"GRANT": {},
44+
"GROUP": {},
45+
"HAVING": {},
46+
"INNER": {},
47+
"INTERSECT": {},
48+
"INTO": {},
49+
"IS": {},
50+
"ISNULL": {},
51+
"JOIN": {},
52+
"LATERAL": {},
53+
"LEADING": {},
54+
"LEFT": {},
55+
"LIKE": {},
56+
"LIMIT": {},
57+
"NOTNULL": {},
58+
"NOT": {},
59+
"NULL": {},
60+
"OFFSET": {},
61+
"ON": {},
62+
"ONLY": {},
63+
"OR": {},
64+
"ORDER": {},
65+
"OVERLAPS": {},
66+
"PLACING": {},
67+
"PRIMARY": {},
68+
"REFERENCES": {},
69+
"RETURNING": {},
70+
"RIGHT": {},
71+
"SELECT": {},
72+
"SIMILAR": {},
73+
"SOME": {},
74+
"SYMMETRIC": {},
75+
"TABLE": {},
76+
"TABLESAMPLE": {},
77+
"THEN": {},
78+
"TO": {},
79+
"TRAILING": {},
80+
"TRUE": {},
81+
"UNION": {},
82+
"UNIQUE": {},
83+
"USER": {},
84+
"USING": {},
85+
"VARIADIC": {},
86+
"VERBOSE": {},
87+
"WHEN": {},
88+
"WHERE": {},
89+
"WINDOW": {},
90+
"WITH": {},
91+
}
92+
93+
// isReservedKeyword checks if the given identifier (case-insensitive) is a PostgreSQL reserved keyword.
94+
func isReservedKeyword(s string) bool {
95+
_, ok := pgReservedKeywords[strings.ToUpper(s)]
96+
return ok
97+
}
98+
99+
// quoteIdentifier wraps an identifier in double quotes, escaping any internal double quotes.
100+
func quoteIdentifier(s string) string {
101+
escaped := strings.ReplaceAll(s, `"`, `""`)
102+
return `"` + escaped + `"`
103+
}
104+
105+
// isAlreadyQuoted checks if an identifier part is already quoted (starts and ends with double quote).
106+
func isAlreadyQuoted(s string) bool {
107+
return len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"'
108+
}
109+
110+
// quoteIdentifierIfKeyword processes an identifier string and quotes parts that are reserved keywords.
111+
// It handles dotted paths by processing each part individually.
112+
// Already quoted parts are left unchanged.
113+
func quoteIdentifierIfKeyword(ident string) string {
114+
if ident == "" || ident == "*" {
115+
return ident
116+
}
117+
118+
// Check for Unicode identifier prefix (U&) - don't modify these
119+
if strings.HasPrefix(ident, "U&") || strings.HasPrefix(ident, "u&") {
120+
return ident
121+
}
122+
123+
// Split by dots to handle dotted paths like schema.table.column
124+
parts := splitIdentifier(ident)
125+
126+
for i, part := range parts {
127+
// Skip if already quoted
128+
if isAlreadyQuoted(part) {
129+
continue
130+
}
131+
132+
// Skip asterisk
133+
if part == "*" {
134+
continue
135+
}
136+
137+
// Quote if it's a reserved keyword
138+
if isReservedKeyword(part) {
139+
parts[i] = quoteIdentifier(part)
140+
}
141+
}
142+
143+
return strings.Join(parts, ".")
144+
}
145+
146+
// splitIdentifier splits an identifier by dots, but respects quoted parts.
147+
// e.g., `schema."my.table".column` -> ["schema", `"my.table"`, "column"]
148+
func splitIdentifier(ident string) []string {
149+
var parts []string
150+
var current strings.Builder
151+
inQuote := false
152+
153+
for i := 0; i < len(ident); i++ {
154+
ch := ident[i]
155+
156+
if ch == '"' {
157+
inQuote = !inQuote
158+
current.WriteByte(ch)
159+
} else if ch == '.' && !inQuote {
160+
parts = append(parts, current.String())
161+
current.Reset()
162+
} else {
163+
current.WriteByte(ch)
164+
}
165+
}
166+
167+
// Add the last part
168+
if current.Len() > 0 {
169+
parts = append(parts, current.String())
170+
}
171+
172+
return parts
173+
}

fn/aggregate_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ func TestAggregateExpressions(t *testing.T) {
1515
t.Run("example 1.1", func(t *testing.T) {
1616
q := qrb.Select(fn.ArrayAgg(qrb.N("a")).OrderBy(qrb.N("b")).Desc()).From(qrb.N("table"))
1717
sql, _, _ := qrb.Build(q).ToSQL()
18-
testhelper.AssertSQLEquals(t, "SELECT array_agg(a ORDER BY b DESC) FROM table", sql)
18+
testhelper.AssertSQLEquals(t, `SELECT array_agg(a ORDER BY b DESC) FROM "table"`, sql)
1919
})
2020

2121
t.Run("example 1.2", func(t *testing.T) {
2222
q := qrb.Select(fn.StringAgg(qrb.N("a"), qrb.String(",")).OrderBy(qrb.N("a"))).From(qrb.N("table"))
2323
sql, _, _ := qrb.Build(q).ToSQL()
24-
testhelper.AssertSQLEquals(t, "SELECT string_agg(a,',' ORDER BY a) FROM table", sql)
24+
testhelper.AssertSQLEquals(t, `SELECT string_agg(a,',' ORDER BY a) FROM "table"`, sql)
2525
})
2626

2727
t.Run("example 1.3", func(t *testing.T) {

fn/functions_string_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,24 @@ func TestLetterCases(t *testing.T) {
1212
t.Run("lower", func(t *testing.T) {
1313
q := qrb.Select(fn.Lower(qrb.N("a"))).From(qrb.N("table"))
1414
sql, _, _ := qrb.Build(q).ToSQL()
15-
testhelper.AssertSQLEquals(t, "SELECT lower(a) FROM table", sql)
15+
testhelper.AssertSQLEquals(t, `SELECT lower(a) FROM "table"`, sql)
1616
})
1717

1818
t.Run("lower with arg", func(t *testing.T) {
1919
q := qrb.Select(qrb.N("id")).From(qrb.N("table")).Where(fn.Lower(qrb.N("name")).Eq(fn.Lower(qrb.Arg("foo"))))
2020
sql, _, _ := qrb.Build(q).ToSQL()
21-
testhelper.AssertSQLEquals(t, "SELECT id FROM table WHERE lower(name) = lower($1)", sql)
21+
testhelper.AssertSQLEquals(t, `SELECT id FROM "table" WHERE lower(name) = lower($1)`, sql)
2222
})
2323

2424
t.Run("upper", func(t *testing.T) {
2525
q := qrb.Select(fn.Upper(qrb.N("a")).Eq(qrb.Arg("foo"))).From(qrb.N("table"))
2626
sql, _, _ := qrb.Build(q).ToSQL()
27-
testhelper.AssertSQLEquals(t, "SELECT upper(a) = $1 FROM table", sql)
27+
testhelper.AssertSQLEquals(t, `SELECT upper(a) = $1 FROM "table"`, sql)
2828
})
2929

3030
t.Run("init cap", func(t *testing.T) {
3131
q := qrb.Select(fn.Initcap(qrb.N("a"))).From(qrb.N("table"))
3232
sql, _, _ := qrb.Build(q).ToSQL()
33-
testhelper.AssertSQLEquals(t, "SELECT initcap(a) FROM table", sql)
33+
testhelper.AssertSQLEquals(t, `SELECT initcap(a) FROM "table"`, sql)
3434
})
3535
}

0 commit comments

Comments
 (0)