Skip to content

Commit 851fe01

Browse files
authored
Add naive postgres create table builder, add QuoteRequiredANSI (#23)
1 parent 0fbada6 commit 851fe01

3 files changed

Lines changed: 344 additions & 0 deletions

File tree

misc/pg_create_table.go

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Package misc contains miscellaneous helpers.
2+
package misc
3+
4+
import (
5+
"database/sql"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/bool64/sqluct"
10+
)
11+
12+
type pgColumn struct {
13+
Name string
14+
DataType string
15+
MaxLength *int
16+
Precision *int
17+
Scale *int
18+
IsNullable string
19+
ColumnDefault *string
20+
}
21+
22+
type pgConstraint struct {
23+
Name string
24+
Type string
25+
Columns string
26+
RefTable *string
27+
RefColumns *string
28+
CheckClause *string
29+
}
30+
31+
// BuildPostgresCreateTable builds a CREATE TABLE statement for Postgres DB.
32+
func BuildPostgresCreateTable(db *sql.DB, schema, table string) (string, error) {
33+
// Get columns
34+
columns, err := getColumns(db, schema, table)
35+
if err != nil {
36+
return "", err
37+
}
38+
39+
// Get constraints
40+
constraints, err := getConstraints(db, schema, table)
41+
if err != nil {
42+
return "", err
43+
}
44+
45+
// Build CREATE TABLE statement
46+
var sb strings.Builder
47+
48+
sb.WriteString(fmt.Sprintf("CREATE TABLE %s.%s (\n", quoteIdentifier(schema), quoteIdentifier(table)))
49+
50+
// Add columns
51+
for i, col := range columns {
52+
if i > 0 {
53+
sb.WriteString(",\n")
54+
}
55+
56+
sb.WriteString(" ")
57+
sb.WriteString(quoteIdentifier(col.Name))
58+
sb.WriteString(" ")
59+
sb.WriteString(formatDataType(col))
60+
61+
if col.IsNullable == "NO" {
62+
sb.WriteString(" NOT NULL")
63+
}
64+
65+
if col.ColumnDefault != nil {
66+
sb.WriteString(" DEFAULT ")
67+
sb.WriteString(*col.ColumnDefault)
68+
}
69+
}
70+
71+
// Add constraints
72+
for _, cons := range constraints {
73+
sb.WriteString(",\n ")
74+
75+
switch cons.Type {
76+
case "PRIMARY KEY":
77+
sb.WriteString(fmt.Sprintf("CONSTRAINT %s PRIMARY KEY (%s)", quoteIdentifier(cons.Name), cons.Columns))
78+
case "UNIQUE":
79+
sb.WriteString(fmt.Sprintf("CONSTRAINT %s UNIQUE (%s)", quoteIdentifier(cons.Name), cons.Columns))
80+
case "FOREIGN KEY":
81+
sb.WriteString(fmt.Sprintf("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)",
82+
quoteIdentifier(cons.Name), cons.Columns, quoteIdentifier(*cons.RefTable), *cons.RefColumns))
83+
case "CHECK":
84+
sb.WriteString(fmt.Sprintf("CONSTRAINT %s CHECK %s", quoteIdentifier(cons.Name), *cons.CheckClause))
85+
}
86+
}
87+
88+
sb.WriteString("\n);")
89+
90+
return sb.String(), nil
91+
}
92+
93+
func getColumns(db *sql.DB, schema, table string) ([]pgColumn, error) {
94+
rows, err := db.Query(`
95+
SELECT
96+
column_name,
97+
data_type,
98+
character_maximum_length,
99+
numeric_precision,
100+
numeric_scale,
101+
is_nullable,
102+
column_default
103+
FROM information_schema.columns
104+
WHERE table_schema = $1 AND table_name = $2
105+
ORDER BY ordinal_position
106+
`, schema, table)
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
defer rows.Close() //nolint:errcheck
112+
113+
var columns []pgColumn
114+
115+
for rows.Next() {
116+
var (
117+
col pgColumn
118+
maxLength, precision, scale sql.NullInt64
119+
colDefault sql.NullString
120+
)
121+
122+
if err := rows.Scan(&col.Name, &col.DataType, &maxLength, &precision, &scale, &col.IsNullable, &colDefault); err != nil {
123+
return nil, err
124+
}
125+
126+
if maxLength.Valid {
127+
v := int(maxLength.Int64)
128+
col.MaxLength = &v
129+
}
130+
131+
if precision.Valid {
132+
v := int(precision.Int64)
133+
col.Precision = &v
134+
}
135+
136+
if scale.Valid {
137+
v := int(scale.Int64)
138+
col.Scale = &v
139+
}
140+
141+
if colDefault.Valid {
142+
col.ColumnDefault = &colDefault.String
143+
}
144+
145+
columns = append(columns, col)
146+
}
147+
148+
return columns, rows.Err()
149+
}
150+
151+
func getConstraints(db *sql.DB, schema, table string) ([]pgConstraint, error) { //nolint:funlen
152+
var constraints []pgConstraint
153+
154+
// Primary Key and Unique Constraints
155+
rows, err := db.Query(`
156+
SELECT
157+
tc.constraint_name,
158+
tc.constraint_type,
159+
string_agg(kcu.column_name, ', ') AS columns
160+
FROM information_schema.table_constraints tc
161+
JOIN information_schema.constraint_column_usage kcu
162+
ON tc.constraint_name = kcu.constraint_name
163+
AND tc.table_schema = kcu.table_schema
164+
AND tc.table_name = kcu.table_name
165+
WHERE tc.table_schema = $1 AND tc.table_name = $2
166+
AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
167+
GROUP BY tc.constraint_name, tc.constraint_type
168+
`, schema, table)
169+
if err != nil {
170+
return nil, err
171+
}
172+
173+
defer rows.Close() //nolint:errcheck
174+
175+
for rows.Next() {
176+
var cons pgConstraint
177+
if err := rows.Scan(&cons.Name, &cons.Type, &cons.Columns); err != nil {
178+
return nil, err
179+
}
180+
181+
constraints = append(constraints, cons)
182+
}
183+
184+
if err := rows.Err(); err != nil {
185+
return nil, err
186+
}
187+
188+
// Foreign Key Constraints
189+
rows, err = db.Query(`
190+
SELECT
191+
tc.constraint_name,
192+
string_agg(kcu.column_name, ', ') AS columns,
193+
ccu.table_name AS ref_table,
194+
string_agg(ccu.column_name, ', ') AS ref_columns
195+
FROM information_schema.table_constraints tc
196+
JOIN information_schema.constraint_column_usage ccu
197+
ON tc.constraint_name = ccu.constraint_name
198+
AND tc.table_schema = ccu.table_schema
199+
JOIN information_schema.key_column_usage kcu
200+
ON tc.constraint_name = kcu.constraint_name
201+
AND tc.table_schema = kcu.table_schema
202+
AND tc.table_name = kcu.table_name
203+
WHERE tc.table_schema = $1 AND tc.table_name = $2
204+
AND tc.constraint_type = 'FOREIGN KEY'
205+
GROUP BY tc.constraint_name, ccu.table_schema, ccu.table_name
206+
`, schema, table)
207+
if err != nil {
208+
return nil, err
209+
}
210+
211+
defer rows.Close() //nolint:errcheck
212+
213+
for rows.Next() {
214+
var (
215+
cons pgConstraint
216+
refTable, refColumns string
217+
)
218+
219+
cons.Type = "FOREIGN KEY"
220+
221+
if err := rows.Scan(&cons.Name, &cons.Columns, &refTable, &refColumns); err != nil {
222+
return nil, err
223+
}
224+
225+
cons.RefTable = &refTable
226+
cons.RefColumns = &refColumns
227+
constraints = append(constraints, cons)
228+
}
229+
230+
if err := rows.Err(); err != nil {
231+
return nil, err
232+
}
233+
234+
// Check Constraints
235+
rows, err = db.Query(`
236+
SELECT
237+
cc.constraint_name,
238+
cc.check_clause
239+
FROM information_schema.check_constraints cc
240+
JOIN information_schema.constraint_table_usage ctu
241+
ON cc.constraint_name = ctu.constraint_name
242+
AND ctu.table_schema = cc.constraint_schema
243+
WHERE ctu.table_schema = $1 AND ctu.table_name = $2
244+
`, schema, table)
245+
if err != nil {
246+
return nil, err
247+
}
248+
defer rows.Close() //nolint:errcheck
249+
250+
for rows.Next() {
251+
var (
252+
cons pgConstraint
253+
checkClause string
254+
)
255+
256+
cons.Type = "CHECK"
257+
if err := rows.Scan(&cons.Name, &checkClause); err != nil {
258+
return nil, err
259+
}
260+
261+
cons.CheckClause = &checkClause
262+
constraints = append(constraints, cons)
263+
}
264+
265+
return constraints, rows.Err()
266+
}
267+
268+
func formatDataType(col pgColumn) string {
269+
switch strings.ToLower(col.DataType) {
270+
case "character varying", "varchar":
271+
if col.MaxLength != nil {
272+
return fmt.Sprintf("VARCHAR(%d)", *col.MaxLength)
273+
}
274+
275+
return "VARCHAR"
276+
case "numeric", "decimal":
277+
if col.Precision != nil && col.Scale != nil {
278+
return fmt.Sprintf("NUMERIC(%d,%d)", *col.Precision, *col.Scale)
279+
}
280+
281+
return "NUMERIC"
282+
default:
283+
return strings.ToUpper(col.DataType)
284+
}
285+
}
286+
287+
func quoteIdentifier(name string) string {
288+
return sqluct.QuoteRequiredANSI(name)
289+
}

referencer.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,55 @@ func QuoteANSI(tableAndColumn ...string) string {
2727
return res.String()
2828
}
2929

30+
// QuoteRequiredANSI quotes symbol names that need quoting with double quotes.
31+
//
32+
// Suitable for PostgreSQL, MySQL in ANSI SQL_MODE, SQLite statements.
33+
func QuoteRequiredANSI(tableAndColumn ...string) string {
34+
res := strings.Builder{}
35+
36+
for i, item := range tableAndColumn {
37+
if i != 0 {
38+
res.WriteString(".")
39+
}
40+
41+
needsQuote := false
42+
onlyDigits := true
43+
44+
for i, r := range item {
45+
if r >= '0' && r <= '9' {
46+
if i == 0 {
47+
needsQuote = true
48+
49+
break
50+
}
51+
52+
continue
53+
}
54+
55+
onlyDigits = false
56+
57+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '$' || r == '_' {
58+
continue
59+
}
60+
61+
needsQuote = true
62+
}
63+
64+
// Identifiers may begin with a digit but unless quoted may not consist solely of digits.
65+
if !needsQuote && !onlyDigits {
66+
res.WriteString(item)
67+
68+
continue
69+
}
70+
71+
res.WriteString(`"`)
72+
res.WriteString(strings.ReplaceAll(item, `"`, `""`))
73+
res.WriteString(`"`)
74+
}
75+
76+
return res.String()
77+
}
78+
3079
// QuoteBackticks quotes symbol names with backticks.
3180
//
3281
// Suitable for MySQL, SQLite statements.

referencer_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ func TestQuoteANSI(t *testing.T) {
145145
assert.Equal(t, `"spacy id"."back`+"`"+`ticky"."quo""ty"`, sqluct.QuoteANSI("spacy id", "back`ticky", `quo"ty`))
146146
}
147147

148+
func TestQuoteRequiredANSI(t *testing.T) {
149+
assert.Equal(t, `one.two`, sqluct.QuoteRequiredANSI("one", "two"))
150+
assert.Equal(t, "", sqluct.QuoteRequiredANSI())
151+
assert.Equal(t, `"spacy id"."back`+"`"+`ticky"."quo""ty".four`, sqluct.QuoteRequiredANSI("spacy id", "back`ticky", `quo"ty`, "four"))
152+
}
153+
148154
// Three benchmarks show different scenarios:
149155
// * full - referencer is recreated for each iteration, formatting is done in each iteration,
150156
// * lite - referencer is reused in all iterations, formatting is done in each iteration,

0 commit comments

Comments
 (0)