Skip to content

Commit 7e9e941

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

File tree

2 files changed

+355
-0
lines changed

2 files changed

+355
-0
lines changed

database/query_builder.go

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

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)