Skip to content

Commit

Permalink
Add dbutil for building mass insert queries
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Feb 24, 2024
1 parent 953608f commit 1a0adaa
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 0 deletions.
91 changes: 91 additions & 0 deletions dbutil/massinsert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package dbutil

import (
"fmt"
"regexp"
"strings"
)

type Array interface {
[1]any | [2]any | [3]any | [4]any | [5]any | [6]any | [7]any | [8]any | [9]any | [10]any | [11]any | [12]any | [13]any | [14]any | [15]any | [16]any | [17]any | [18]any | [19]any | [20]any
}

type MassInsertable[T Array] interface {
GetMassInsertValues() T
}

type MassInsertBuilder[Item MassInsertable[DynamicParams], StaticParams Array, DynamicParams Array] struct {
queryTemplate string
placeholderTemplate string
}

func NewMassInsertBuilder[Item MassInsertable[DynamicParams], StaticParams Array, DynamicParams Array](
singleInsertQuery, placeholderTemplate string,
) *MassInsertBuilder[Item, StaticParams, DynamicParams] {
var dyn DynamicParams
var stat StaticParams
totalParams := len(dyn) + len(stat)
mainQueryVariablePlaceholderParts := make([]string, totalParams)
for i := 0; i < totalParams; i++ {
mainQueryVariablePlaceholderParts[i] = fmt.Sprintf(`\$%d`, i+1)
}
mainQueryVariablePlaceholderRegex := regexp.MustCompile(fmt.Sprintf(`\(\s*%s\s*\)`, strings.Join(mainQueryVariablePlaceholderParts, `\s*,\s*`)))
queryPlaceholders := mainQueryVariablePlaceholderRegex.FindAllString(singleInsertQuery, -1)
if len(queryPlaceholders) == 0 {
panic(fmt.Errorf("invalid insert query: placeholders not found"))
} else if len(queryPlaceholders) > 1 {
panic(fmt.Errorf("invalid insert query: multiple placeholders found"))
}
for i := 0; i < len(stat); i++ {
if !strings.Contains(placeholderTemplate, fmt.Sprintf("$%d", i+1)) {
panic(fmt.Errorf("invalid placeholder template: static placeholder $%d not found", i+1))
}
}
if strings.Contains(placeholderTemplate, fmt.Sprintf("$%d", len(stat)+1)) {
panic(fmt.Errorf("invalid placeholder template: non-static placeholder $%d found", len(stat)+1))
}
fmtParams := make([]any, len(dyn))
for i := 0; i < len(dyn); i++ {
fmtParams[i] = fmt.Sprintf("$%d", len(stat)+i+1)
}
formattedPlaceholder := fmt.Sprintf(placeholderTemplate, fmtParams...)
if strings.Contains(formattedPlaceholder, "!(EXTRA string=") {
panic(fmt.Errorf("invalid placeholder template: extra string found"))
}
for i := 0; i < len(dyn); i++ {
if !strings.Contains(formattedPlaceholder, fmt.Sprintf("$%d", len(stat)+i+1)) {
panic(fmt.Errorf("invalid placeholder template: dynamic placeholder $%d not found", len(stat)+i+1))
}
}
return &MassInsertBuilder[Item, StaticParams, DynamicParams]{
queryTemplate: strings.Replace(singleInsertQuery, queryPlaceholders[0], "%s", 1),
placeholderTemplate: placeholderTemplate,
}
}

func (mib *MassInsertBuilder[Item, StaticParams, DynamicParams]) Build(static StaticParams, data []Item) (query string, params []any) {
var itemValues DynamicParams
params = make([]any, len(static)+len(itemValues)*len(data))
placeholders := make([]string, len(data))
for i := 0; i < len(static); i++ {
params[i] = static[i]
}
fmtParams := make([]any, len(itemValues))
for i, item := range data {
baseIndex := len(static) + len(itemValues)*i
itemValues = item.GetMassInsertValues()
for j := 0; j < len(itemValues); j++ {
params[baseIndex+j] = itemValues[j]
fmtParams[j] = baseIndex + j + 1
}
placeholders[i] = fmt.Sprintf(mib.placeholderTemplate, fmtParams...)
}
query = fmt.Sprintf(mib.queryTemplate, strings.Join(placeholders, ", "))
return
}
47 changes: 47 additions & 0 deletions dbutil/massinsert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package dbutil_test

import (
"testing"

"github.com/stretchr/testify/assert"

"go.mau.fi/util/dbutil"
)

type AbstractMassInsertable[T dbutil.Array] struct {
Data T
}

func (a AbstractMassInsertable[T]) GetMassInsertValues() T {
return a.Data
}

type OneParamMassInsertable = AbstractMassInsertable[[1]any]

func TestNewMassInsertBuilder_InvalidParams(t *testing.T) {
assert.PanicsWithError(t, "invalid insert query: placeholders not found", func() {
dbutil.NewMassInsertBuilder[OneParamMassInsertable, [1]any]("", "")
})
assert.PanicsWithError(t, "invalid placeholder template: static placeholder $1 not found", func() {
dbutil.NewMassInsertBuilder[OneParamMassInsertable, [1]any]("INSERT INTO foo VALUES ($1, $2)", "")
})
assert.PanicsWithError(t, "invalid placeholder template: non-static placeholder $2 found", func() {
dbutil.NewMassInsertBuilder[OneParamMassInsertable, [1]any]("INSERT INTO foo VALUES ($1, $2)", "($1, $2)")
})
assert.PanicsWithError(t, "invalid placeholder template: extra string found", func() {
dbutil.NewMassInsertBuilder[OneParamMassInsertable, [1]any]("INSERT INTO foo VALUES ($1, $2)", "($1)")
})
}

func TestMassInsertBuilder_Build(t *testing.T) {
builder := dbutil.NewMassInsertBuilder[OneParamMassInsertable, [1]any]("INSERT INTO foo VALUES ($1, $2)", "($1, $%d)")
query, values := builder.Build([1]any{"hi"}, []OneParamMassInsertable{{[1]any{"hmm"}}, {[1]any{"meow"}}, {[1]any{"third"}}})
assert.Equal(t, "INSERT INTO foo VALUES ($1, $2), ($1, $3), ($1, $4)", query)
assert.Equal(t, []any{"hi", "hmm", "meow", "third"}, values)
}
28 changes: 28 additions & 0 deletions exslices/chunk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package exslices

// Chunk splits a slice into chunks of the given size.
//
// From https://github.com/golang/go/issues/53987#issuecomment-1224367139
//
// TODO remove this after slices.Chunk can be used (it'll probably be added in Go 1.23, so it can be used after 1.22 is EOL)
func Chunk[T any](slice []T, size int) (chunks [][]T) {
if size < 1 {
panic("chunk size cannot be less than 1")
}
for i := 0; ; i++ {
next := i * size
if len(slice[next:]) > size {
end := next + size
chunks = append(chunks, slice[next:end:end])
} else {
chunks = append(chunks, slice[i*size:])
return
}
}
}

0 comments on commit 1a0adaa

Please sign in to comment.