Skip to content

Commit 7c01a0b

Browse files
committed
Introduce QueryBuilder type
1 parent 5a13b24 commit 7c01a0b

File tree

2 files changed

+354
-0
lines changed

2 files changed

+354
-0
lines changed

database/query_builder.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package database
2+
3+
import (
4+
"fmt"
5+
"github.com/icinga/icinga-go-library/driver"
6+
"golang.org/x/exp/slices"
7+
"strings"
8+
)
9+
10+
// QueryBuilder is an addon for the DB type that takes care of all the database statement building shenanigans.
11+
// Note: This type is designed primarily for one-off use (monouso) and subsequent disposal and should only be
12+
// used to generate a single database query type.
13+
type QueryBuilder struct {
14+
subject interface{}
15+
columns []string
16+
excludedColumns []string
17+
}
18+
19+
// NewQB returns a fully initialized *QueryBuilder instance for the given subject/struct.
20+
func NewQB(subject interface{}) *QueryBuilder {
21+
return &QueryBuilder{subject: subject}
22+
}
23+
24+
// SetColumns sets the DB columns to be used when building the statements.
25+
// When you do not want the columns to be extracted dynamically, you can use this method to specify them manually.
26+
func (qb *QueryBuilder) SetColumns(columns ...string) {
27+
qb.columns = columns
28+
}
29+
30+
// Exclude excludes the given columns from all the database statements.
31+
func (qb *QueryBuilder) Exclude(columns ...string) {
32+
qb.excludedColumns = columns
33+
}
34+
35+
// Delete returns a DELETE statement for the query builders subject filtered by ID.
36+
func (qb *QueryBuilder) Delete() string {
37+
return qb.DeleteBy("id")
38+
}
39+
40+
// DeleteBy returns a DELETE statement for the query builders subject filtered by the given column.
41+
func (qb *QueryBuilder) DeleteBy(column string) string {
42+
return fmt.Sprintf(`DELETE FROM "%s" WHERE "%s" IN (?)`, TableName(qb.subject), column)
43+
}
44+
45+
// Insert returns an INSERT INTO statement for the query builders subject.
46+
func (qb *QueryBuilder) Insert(db *DB) (string, int) {
47+
columns := qb.BuildColumns(db)
48+
49+
return fmt.Sprintf(
50+
`INSERT INTO "%s" ("%s") VALUES (%s)`,
51+
TableName(qb.subject),
52+
strings.Join(columns, `", "`),
53+
fmt.Sprintf(":%s", strings.Join(columns, ", :")),
54+
), len(columns)
55+
}
56+
57+
// InsertIgnore returns an INSERT statement for the query builders subject for
58+
// which the database ignores rows that have already been inserted.
59+
func (qb *QueryBuilder) InsertIgnore(db *DB) (string, int) {
60+
columns := qb.BuildColumns(db)
61+
table := TableName(qb.subject)
62+
63+
var clause string
64+
switch db.DriverName() {
65+
case driver.MySQL:
66+
// MySQL treats UPDATE id = id as a no-op.
67+
clause = fmt.Sprintf(`ON DUPLICATE KEY UPDATE "%s" = "%s"`, columns[0], columns[0])
68+
case driver.PostgreSQL:
69+
clause = fmt.Sprintf("ON CONFLICT ON CONSTRAINT pk_%s DO NOTHING", table)
70+
}
71+
72+
return fmt.Sprintf(
73+
`INSERT INTO "%s" ("%s") VALUES (%s) %s`,
74+
table,
75+
strings.Join(columns, `", "`),
76+
fmt.Sprintf(":%s", strings.Join(columns, ", :")),
77+
clause,
78+
), len(columns)
79+
}
80+
81+
// Select returns a SELECT statement from the query builders subject for the specified columns struct.
82+
// When the query builders subject is of type Scoper, a WHERE clause is appended to the statement.
83+
// Note: Excluded columns have no influence on the result of this method, as you're explicitly providing
84+
// which columns are going to be selected.
85+
func (qb *QueryBuilder) Select(db *DB, columns interface{}) string {
86+
var scoper Scoper
87+
if sc, ok := qb.subject.(Scoper); ok {
88+
scoper = sc
89+
}
90+
91+
return qb.SelectColumns(db, scoper, db.BuildColumns(columns)...)
92+
}
93+
94+
// SelectColumns returns a SELECT statement from the query builders subject for the specified columns
95+
// filtered by the given scoper/column. Note: The scoper argument must be either of type Scoper or string.
96+
// Note: Excluded columns have no influence on the result of this method, as you're explicitly providing
97+
// which columns are going to be selected.
98+
func (qb *QueryBuilder) SelectColumns(db *DB, scoper interface{}, columns ...string) string {
99+
query := fmt.Sprintf(`SELECT "%s" FROM "%s"`, strings.Join(columns, `", "`), TableName(qb.subject))
100+
where, placeholders := qb.Where(db, scoper)
101+
if placeholders > 0 {
102+
query += ` WHERE ` + where
103+
}
104+
105+
return query
106+
}
107+
108+
// Update returns an UPDATE statement for the query builders subject filter by ID column.
109+
func (qb *QueryBuilder) Update(db *DB) (string, int) {
110+
return qb.UpdateScoped(db, "id")
111+
}
112+
113+
// UpdateScoped returns an UPDATE statement for the query builders subject filtered by the given column/scoper.
114+
// Note: The scoper argument must be either of type Scoper or string.
115+
func (qb *QueryBuilder) UpdateScoped(db *DB, scoper interface{}) (string, int) {
116+
columns := qb.BuildColumns(db)
117+
set := make([]string, 0, len(columns))
118+
119+
for _, col := range columns {
120+
set = append(set, fmt.Sprintf(`"%s" = :%s`, col, col))
121+
}
122+
123+
placeholders := len(columns)
124+
query := `UPDATE "%s" SET %s`
125+
if where, count := qb.Where(db, scoper); count > 0 {
126+
placeholders += count
127+
query += ` WHERE ` + where
128+
}
129+
130+
return fmt.Sprintf(query, TableName(qb.subject), strings.Join(set, ", ")), placeholders
131+
}
132+
133+
// Upsert returns an upsert statement for the query builders subject.
134+
func (qb *QueryBuilder) Upsert(db *DB) (string, int) {
135+
var updateColumns []string
136+
if upserter, ok := qb.subject.(Upserter); ok {
137+
updateColumns = db.BuildColumns(upserter.Upsert())
138+
} else {
139+
updateColumns = qb.BuildColumns(db)
140+
}
141+
142+
return qb.UpsertColumns(db, updateColumns...)
143+
}
144+
145+
// UpsertColumns returns an upsert statement for the query builders subject and the specified update columns.
146+
func (qb *QueryBuilder) UpsertColumns(db *DB, updateColumns ...string) (string, int) {
147+
insertColumns := qb.BuildColumns(db)
148+
table := TableName(qb.subject)
149+
150+
var clause, setFormat string
151+
switch db.DriverName() {
152+
case driver.MySQL:
153+
clause = "ON DUPLICATE KEY UPDATE"
154+
setFormat = `"%[1]s" = VALUES("%[1]s")`
155+
case driver.PostgreSQL:
156+
clause = fmt.Sprintf("ON CONFLICT ON CONSTRAINT pk_%s DO UPDATE SET", table)
157+
setFormat = `"%[1]s" = EXCLUDED."%[1]s"`
158+
}
159+
160+
set := make([]string, 0, len(updateColumns))
161+
for _, col := range updateColumns {
162+
set = append(set, fmt.Sprintf(setFormat, col))
163+
}
164+
165+
return fmt.Sprintf(
166+
`INSERT INTO "%s" ("%s") VALUES (%s) %s %s`,
167+
table,
168+
strings.Join(insertColumns, `", "`),
169+
fmt.Sprintf(":%s", strings.Join(insertColumns, ", :")),
170+
clause,
171+
strings.Join(set, ", "),
172+
), len(insertColumns)
173+
}
174+
175+
// Where returns a WHERE clause with named placeholder conditions built from the
176+
// specified scoper/column combined with the AND operator.
177+
func (qb *QueryBuilder) Where(db *DB, scoper interface{}) (string, int) {
178+
var columns []string
179+
switch subject := scoper.(type) {
180+
case string:
181+
columns = []string{subject}
182+
case Scoper:
183+
return qb.Where(db, subject.Scope())
184+
case interface{}:
185+
columns = db.BuildColumns(scoper)
186+
default:
187+
// Invalid type
188+
}
189+
190+
where := make([]string, 0, len(columns))
191+
for _, col := range columns {
192+
where = append(where, fmt.Sprintf(`"%s" = :%s`, col, col))
193+
}
194+
195+
return strings.Join(where, ` AND `), len(columns)
196+
}
197+
198+
// BuildColumns returns all the Query Builder columns (if specified), otherwise they are
199+
// determined dynamically using its subject. Additionally, it checks whether columns need
200+
// to be excluded and proceeds accordingly.
201+
func (qb *QueryBuilder) BuildColumns(db *DB) []string {
202+
var columns []string
203+
if len(qb.columns) > 0 {
204+
columns = qb.columns
205+
} else {
206+
columns = db.BuildColumns(qb.subject)
207+
}
208+
209+
if len(qb.excludedColumns) > 0 {
210+
columns = slices.DeleteFunc(append([]string(nil), columns...), func(column string) bool {
211+
for _, exclude := range qb.excludedColumns {
212+
if exclude == column {
213+
return true
214+
}
215+
}
216+
217+
return false
218+
})
219+
}
220+
221+
return columns
222+
}

database/query_builder_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package database
2+
3+
import (
4+
"github.com/icinga/icinga-go-library/driver"
5+
"github.com/stretchr/testify/assert"
6+
"github.com/stretchr/testify/require"
7+
"testing"
8+
)
9+
10+
func TestQueryBuilder(t *testing.T) {
11+
t.Parallel()
12+
13+
conf := Config{
14+
Type: "mysql",
15+
Host: "localhost",
16+
Port: 3306,
17+
Database: "igl-test",
18+
User: "igl-test",
19+
Password: "igl-test",
20+
}
21+
22+
driver.Register(nil)
23+
db, err := NewDbFromConfig(&conf, nil)
24+
require.NoError(t, err)
25+
26+
t.Run("SetColumns", func(t *testing.T) {
27+
qb := NewQB("test")
28+
qb.SetColumns("a", "b")
29+
require.Equal(t, []string{"a", "b"}, qb.columns)
30+
})
31+
32+
t.Run("ExcludeColumns", func(t *testing.T) {
33+
qb := NewQB(&test{})
34+
qb.Exclude("a", "b")
35+
require.Equal(t, []string{"a", "b"}, qb.excludedColumns)
36+
})
37+
38+
t.Run("DeleteStatements", func(t *testing.T) {
39+
qb := NewQB(&test{})
40+
require.Equal(t, `DELETE FROM "test" WHERE "id" IN (?)`, qb.Delete())
41+
require.Equal(t, `DELETE FROM "test" WHERE "foo" IN (?)`, qb.DeleteBy("foo"))
42+
})
43+
44+
t.Run("InsertStatements", func(t *testing.T) {
45+
qb := NewQB(&test{})
46+
qb.Exclude("random")
47+
48+
stmt, columns := qb.Insert(db)
49+
require.Equal(t, 2, columns)
50+
require.Equal(t, `INSERT INTO "test" ("name", "value") VALUES (:name, :value)`, stmt)
51+
52+
qb.Exclude("a", "b")
53+
qb.SetColumns("a", "b", "c", "d")
54+
55+
stmt, columns = qb.Insert(db)
56+
require.Equal(t, 2, columns)
57+
require.Equal(t, `INSERT INTO "test" ("c", "d") VALUES (:c, :d)`, stmt)
58+
59+
stmt, columns = qb.InsertIgnore(db)
60+
require.Equal(t, 2, columns)
61+
require.Equal(t, `INSERT INTO "test" ("c", "d") VALUES (:c, :d) ON DUPLICATE KEY UPDATE "c" = "c"`, stmt)
62+
})
63+
64+
t.Run("SelectStatements", func(t *testing.T) {
65+
qb := NewQB(&test{})
66+
stmt := qb.Select(db, &test{})
67+
expected := `SELECT "name", "value", "random" FROM "test" WHERE "scoper_id" = :scoper_id`
68+
// The order in which the columns appear is not guaranteed as we extract the columns dynamically from the struct.
69+
require.True(t, assert.ObjectsAreEqual(expected, stmt), "Expected: %q\nActual: %q", expected, stmt)
70+
71+
stmt = qb.SelectColumns(db, "name", "name", "value", "random")
72+
require.Equal(t, `SELECT "name", "value", "random" FROM "test" WHERE "name" = :name`, stmt)
73+
})
74+
75+
t.Run("UpdateStatements", func(t *testing.T) {
76+
qb := NewQB(&test{})
77+
qb.Exclude("random")
78+
79+
stmt, placeholders := qb.Update(db)
80+
require.Equal(t, 3, placeholders)
81+
82+
expected := `UPDATE "test" SET "name" = :name, "value" = :value WHERE "id" = :id`
83+
// The order in which the columns appear is not guaranteed as we extract the columns dynamically from the struct.
84+
require.True(t, assert.ObjectsAreEqual(expected, stmt), "Expected: %q\nActual: %q", expected, stmt)
85+
86+
stmt, placeholders = qb.UpdateScoped(db, (&test{}).Scope())
87+
require.Equal(t, 3, placeholders)
88+
require.Equal(t, `UPDATE "test" SET "name" = :name, "value" = :value WHERE "scoper_id" = :scoper_id`, stmt)
89+
90+
qb.Exclude("a", "b")
91+
qb.SetColumns("a", "b", "c", "d")
92+
93+
stmt, placeholders = qb.UpdateScoped(db, "c")
94+
require.Equal(t, 3, placeholders)
95+
require.Equal(t, 3, placeholders)
96+
require.Equal(t, `UPDATE "test" SET "c" = :c, "d" = :d WHERE "c" = :c`, stmt)
97+
})
98+
99+
t.Run("UpsertStatements", func(t *testing.T) {
100+
qb := NewQB(&test{})
101+
qb.Exclude("random")
102+
103+
stmt, columns := qb.Upsert(db)
104+
require.Equal(t, 2, columns)
105+
require.Equal(t, `INSERT INTO "test" ("name", "value") VALUES (:name, :value) ON DUPLICATE KEY UPDATE "name" = VALUES("name"), "value" = VALUES("value")`, stmt)
106+
107+
qb.Exclude("a", "b")
108+
qb.SetColumns("a", "b", "c", "d")
109+
110+
stmt, columns = qb.Upsert(db)
111+
require.Equal(t, 2, columns)
112+
require.Equal(t, `INSERT INTO "test" ("c", "d") VALUES (:c, :d) ON DUPLICATE KEY UPDATE "c" = VALUES("c"), "d" = VALUES("d")`, stmt)
113+
114+
qb.Exclude("a")
115+
116+
stmt, columns = qb.UpsertColumns(db, "b", "c")
117+
require.Equal(t, 3, columns)
118+
require.Equal(t, `INSERT INTO "test" ("b", "c", "d") VALUES (:b, :c, :d) ON DUPLICATE KEY UPDATE "b" = VALUES("b"), "c" = VALUES("c")`, stmt)
119+
})
120+
}
121+
122+
type test struct {
123+
Name string
124+
Value string
125+
Random string
126+
}
127+
128+
func (t *test) Scope() any {
129+
return struct {
130+
ScoperID string
131+
}{}
132+
}

0 commit comments

Comments
 (0)