(Feat): Initial Commit
This commit is contained in:
109
internal/util/db/db.go
Normal file
109
internal/util/db/db.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
)
|
||||
|
||||
type TxFn func(boil.ContextExecutor) error
|
||||
|
||||
func WithTransaction(ctx context.Context, db *sql.DB, txHandler TxFn) error {
|
||||
return WithConfiguredTransaction(ctx, db, nil, txHandler)
|
||||
}
|
||||
|
||||
func WithConfiguredTransaction(ctx context.Context, db *sql.DB, options *sql.TxOptions, txHandler TxFn) error {
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to start transaction")
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
cause := recover()
|
||||
|
||||
switch {
|
||||
case cause != nil:
|
||||
util.LogFromContext(ctx).Error().Interface("cause", cause).Msg("Recovered from panic, rolling back transaction and panicking again")
|
||||
|
||||
if txErr := tx.Rollback(); txErr != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after recovering from panic")
|
||||
}
|
||||
|
||||
panic(cause)
|
||||
case err != nil:
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Received error, rolling back transaction")
|
||||
|
||||
if txErr := tx.Rollback(); txErr != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after receiving error")
|
||||
}
|
||||
default:
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to commit transaction")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = txHandler(tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NullIntFromInt64Ptr(i *int64) null.Int {
|
||||
if i == nil {
|
||||
return null.NewInt(0, false)
|
||||
}
|
||||
|
||||
return null.NewInt(int(*i), true)
|
||||
}
|
||||
|
||||
func NullFloat32FromFloat64Ptr(f *float64) null.Float32 {
|
||||
if f == nil {
|
||||
return null.NewFloat32(0.0, false)
|
||||
}
|
||||
|
||||
return null.NewFloat32(float32(*f), true)
|
||||
}
|
||||
|
||||
func NullIntFromInt16Ptr(i *int16) null.Int {
|
||||
if i == nil {
|
||||
return null.NewInt(0, false)
|
||||
}
|
||||
|
||||
return null.NewInt(int(*i), true)
|
||||
}
|
||||
|
||||
func Int16PtrFromNullInt(i null.Int) *int16 {
|
||||
if !i.Valid || i.Int > math.MaxInt16 || i.Int < math.MinInt16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := int16(i.Int)
|
||||
return &res
|
||||
}
|
||||
|
||||
func Int16PtrFromInt(i int) *int16 {
|
||||
if i > math.MaxInt16 || i < math.MinInt16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := int16(i)
|
||||
return &res
|
||||
}
|
||||
|
||||
func NullStringIfEmpty(s string) null.String {
|
||||
if len(s) == 0 {
|
||||
return null.String{}
|
||||
}
|
||||
|
||||
return null.StringFrom(s)
|
||||
}
|
||||
190
internal/util/db/db_test.go
Normal file
190
internal/util/db/db_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/aarondl/sqlboiler/v4/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithTransactionSuccess(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
if err := newUser.Insert(ctx, tx, boil.Infer()); err != nil {
|
||||
return fmt.Errorf("failed to insert user: %w", err)
|
||||
}
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete all users: %w", err)
|
||||
}
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTransactionWithError(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err := newUser.Insert(ctx, tx, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
newUser2 := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
}
|
||||
|
||||
return newUser2.Insert(ctx, tx, boil.Infer())
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count, newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTransactionWithPanic(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
panicFunc := func() {
|
||||
_ = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err := newUser.Insert(ctx, tx, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
panic("some panic")
|
||||
})
|
||||
}
|
||||
|
||||
require.Panics(t, panicFunc)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count, newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDBTypeConversions(t *testing.T) {
|
||||
i64 := int64(19)
|
||||
res := db.NullIntFromInt64Ptr(&i64)
|
||||
assert.Equal(t, 19, res.Int)
|
||||
assert.True(t, res.Valid)
|
||||
|
||||
res = db.NullIntFromInt64Ptr(nil)
|
||||
assert.False(t, res.Valid)
|
||||
|
||||
f := 19.9999
|
||||
res2 := db.NullFloat32FromFloat64Ptr(&f)
|
||||
assert.InDelta(t, float32(f), res2.Float32, 0.0001)
|
||||
assert.True(t, res2.Valid)
|
||||
|
||||
res2 = db.NullFloat32FromFloat64Ptr(nil)
|
||||
assert.False(t, res2.Valid)
|
||||
|
||||
i16 := int16(19)
|
||||
res3 := db.NullIntFromInt16Ptr(&i16)
|
||||
assert.Equal(t, 19, res3.Int)
|
||||
assert.True(t, res3.Valid)
|
||||
|
||||
res4 := db.Int16PtrFromNullInt(res3)
|
||||
require.NotEmpty(t, res4)
|
||||
assert.Equal(t, i16, *res4)
|
||||
|
||||
res5 := db.Int16PtrFromNullInt(null.IntFromPtr(nil))
|
||||
assert.Empty(t, res5)
|
||||
|
||||
i := 7
|
||||
res6 := db.Int16PtrFromInt(i)
|
||||
require.NotEmpty(t, res6)
|
||||
assert.Equal(t, i, int(*res6))
|
||||
|
||||
res7 := db.NullStringIfEmpty("")
|
||||
assert.False(t, res7.Valid)
|
||||
|
||||
s := "foo"
|
||||
res8 := db.NullStringIfEmpty(s)
|
||||
assert.True(t, res8.Valid)
|
||||
assert.Equal(t, s, res8.String)
|
||||
}
|
||||
69
internal/util/db/example_test.go
Normal file
69
internal/util/db/example_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
type PublicName struct {
|
||||
First string `json:"firstName"`
|
||||
}
|
||||
|
||||
type Name struct {
|
||||
PublicName
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
}
|
||||
|
||||
type UserFilter struct {
|
||||
Name
|
||||
Country string `json:"country"`
|
||||
City string
|
||||
Scopes []string `json:"scopes"`
|
||||
Age *int `json:"age"`
|
||||
Height *float32 `json:"height"`
|
||||
}
|
||||
|
||||
func ExampleWhereJSON() {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
fmt.Println(sql)
|
||||
fmt.Print("[")
|
||||
for i := range args {
|
||||
if i < len(args)-1 {
|
||||
fmt.Printf("%v, ", args[i])
|
||||
} else {
|
||||
fmt.Printf("%v", args[i])
|
||||
}
|
||||
}
|
||||
fmt.Println("]")
|
||||
|
||||
// Output:
|
||||
// SELECT * FROM "users" WHERE (users.profile->>'firstName' = $1 AND users.profile->>'lastName' = $2 AND users.profile->>'country' = $3 AND users.profile->'scopes' <@ to_jsonb($4::text[]) AND users.profile->>'age' = $5);
|
||||
// [Max, Muster, Austria, &[app user_info], 42]
|
||||
}
|
||||
47
internal/util/db/ilike.go
Normal file
47
internal/util/db/ilike.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
var (
|
||||
likeQueryEscapeRegex = regexp.MustCompile(`(%|_)`)
|
||||
likeQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// ILike returns a query mod containing a pre-formatted ILIKE clause.
|
||||
// The value provided is applied directly - to perform a wildcard search,
|
||||
// enclose the desired search value in `%` as desired before passing it
|
||||
// to ILike.
|
||||
// The path provided will be joined to construct the full SQL path used,
|
||||
// allowing for filtering of values nested across multiple joins if needed.
|
||||
func ILike(val string, path ...string) qm.QueryMod {
|
||||
// ! Attention: we **must** use ? instead of $1 or similar to bind query parameters here since
|
||||
// ! other parts of the query might have already defined $1, leading to incorrect parameters
|
||||
// ! being inserted. On the contrary to other parts using PG queries, ? actually works with qm.Where.
|
||||
return qm.Where(fmt.Sprintf("%s ILIKE ?", strings.Join(path, ".")), val)
|
||||
}
|
||||
|
||||
// ILikeSearch returns a query mod with one or multiple ILIKE clauses in an
|
||||
// AND expression.
|
||||
// The query is split on whitespace characters and for each word an escaped
|
||||
// ILIKE with prefix and suffix wildcard will be generated.
|
||||
func ILikeSearch(query string, path ...string) qm.QueryMod {
|
||||
res := []qm.QueryMod{}
|
||||
|
||||
terms := likeQueryWhiteSpaceRegex.Split(strings.TrimSpace(query), -1)
|
||||
for _, t := range terms {
|
||||
res = append(res, ILike("%"+EscapeLike(t)+"%", path...))
|
||||
}
|
||||
|
||||
return qm.Expr(res...)
|
||||
}
|
||||
|
||||
// EscapeLike escapes a string to be placed in an ILIKE query.
|
||||
func EscapeLike(val string) string {
|
||||
return likeQueryEscapeRegex.ReplaceAllString(val, "\\$1")
|
||||
}
|
||||
46
internal/util/db/ilike_test.go
Normal file
46
internal/util/db/ilike_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestILike(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.ILike("%Max.Muster%", "users", "username"),
|
||||
db.ILike("Max", "users", "app_user_profiles", "first_name"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestEscapeLike(t *testing.T) {
|
||||
res := db.EscapeLike("%foo% _b%a_r%")
|
||||
assert.Equal(t, "\\%foo\\% \\_b\\%a\\_r\\%", res)
|
||||
}
|
||||
|
||||
func TestILikeSearch(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.ILikeSearch(" mus%ter m_ax ", "users", "username"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
63
internal/util/db/join.go
Normal file
63
internal/util/db/join.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
// InnerJoinWithFilter returns an InnerJoin QueryMod formatted using the provided join tables and columns including an
|
||||
// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter.
|
||||
func InnerJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod {
|
||||
filterTable := joinTable
|
||||
if len(optFilterTable) > 0 {
|
||||
filterTable = optFilterTable[0]
|
||||
}
|
||||
|
||||
return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn,
|
||||
filterTable,
|
||||
filterColumn), filterValue)
|
||||
}
|
||||
|
||||
// InnerJoin returns an InnerJoin QueryMod formatted using the provided join tables and columns.
|
||||
func InnerJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod {
|
||||
return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn))
|
||||
}
|
||||
|
||||
// LeftOuterJoin returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns.
|
||||
func LeftOuterJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod {
|
||||
return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn))
|
||||
}
|
||||
|
||||
// LeftOuterJoinWithFilter returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns including an
|
||||
// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter.
|
||||
func LeftOuterJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod {
|
||||
filterTable := joinTable
|
||||
if len(optFilterTable) > 0 {
|
||||
filterTable = optFilterTable[0]
|
||||
}
|
||||
|
||||
return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn,
|
||||
filterTable,
|
||||
filterColumn), filterValue)
|
||||
}
|
||||
104
internal/util/db/join_test.go
Normal file
104
internal/util/db/join_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInnerJoinWithFilter(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
profiles, err := models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
models.UserColumns.Username,
|
||||
"user1@example.com",
|
||||
)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
|
||||
profiles, err = models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
models.UserColumns.Username,
|
||||
"user1@example.com",
|
||||
models.TableNames.Users,
|
||||
)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInnerJoin(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
profiles, err := models.AppUserProfiles(db.InnerJoin(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
),
|
||||
models.UserWhere.Username.EQ(null.StringFrom("user1@example.com")),
|
||||
).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLeftOuterJoinWithFilter(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
|
||||
query = models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max", "app_user_profiles"),
|
||||
)
|
||||
|
||||
sql, args = queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestLeftOuterJoin(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
126
internal/util/db/json.go
Normal file
126
internal/util/db/json.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
whereJSONMaxLevel = 10
|
||||
)
|
||||
|
||||
// WhereJSON constructs a QueryMod for querying a JSONB column.
|
||||
//
|
||||
// The filter interface provided is inspected using reflection, all fields with
|
||||
// a (non-empty) `json` tag will be added to the query and combined using `AND` -
|
||||
// fields tagged with `json:"-"` will be ignored as well. Alternatively, a string
|
||||
// can be provided, performing a string comparison with the database value (the
|
||||
// stored JSON value does not necessarily have to be a string, but could be an
|
||||
// integer or similar). The `json` tag's (first) value will be used as the "key"
|
||||
// for the query, allowing for field renaming or different capitalizations.
|
||||
//
|
||||
// At the moment, the root level `filter` value must either be a struct or a string.
|
||||
// WhereJSON will panic should it encounter a type it cannot process or the filter
|
||||
// provided results in an empty QueryMod - this allows for easier call chaining
|
||||
// at the expense of panics in case of incorrect filters being passed.
|
||||
//
|
||||
// WhereJSON should support all basic types as well as pointers and array/slices
|
||||
// of those out of the box, given the Postgres driver can handle their serialization.
|
||||
// nil pointers are skipped automatically.
|
||||
// At the moment, struct fields are only supported for composition purposes: if a
|
||||
// struct is encountered, WhereJSON recursively traverses it (up to 10 levels deep)
|
||||
// and adds all eligible fields to the top level query.
|
||||
// Should an array or slice be encountered, their values will be added using the
|
||||
// `<@` JSONB operator, checking whether all entries existx at the top level within
|
||||
// the JSON column.
|
||||
// At the time of writing, no support for special database/HTTP types such as the
|
||||
// `null` or `strfmt` packages exists - use their respective base types instead.
|
||||
//
|
||||
// Whilst WhereJSON was designed to be used with Postgres' JSONB column type, the
|
||||
// current implementation also supports the JSON type as long as the filter struct
|
||||
// does not contain any arrays or slices. Note that this compatibility might change
|
||||
// at some point in the future, so it is advised to use the JSONB data type unless
|
||||
// your requirements do not allow for it.
|
||||
func WhereJSON(table string, column string, filter interface{}) qm.QueryMod {
|
||||
qms := whereJSON(table, column, filter, 0)
|
||||
if len(qms) == 0 {
|
||||
panic(errors.New("filter resulted in empty query"))
|
||||
}
|
||||
|
||||
return qm.Expr(qms...)
|
||||
}
|
||||
|
||||
func whereJSON(table string, column string, filter interface{}, level int) []qm.QueryMod {
|
||||
if level >= whereJSONMaxLevel {
|
||||
panic(fmt.Errorf("whereJSON reached maximum recursion (%d/%d)", level, whereJSONMaxLevel))
|
||||
}
|
||||
|
||||
qms := make([]qm.QueryMod, 0)
|
||||
|
||||
filterType := reflect.TypeOf(filter)
|
||||
switch filterType.Kind() {
|
||||
case reflect.Struct:
|
||||
filterValue := reflect.ValueOf(filter)
|
||||
for i := 0; i < filterType.NumField(); i++ {
|
||||
field := filterType.Field(i)
|
||||
|
||||
// skip unexported fields as we cannot retrieve their values
|
||||
if len(field.PkgPath) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonkey := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonkey == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
filterValueField := filterValue.Field(i)
|
||||
if filterValueField.Kind() != reflect.Struct && jsonkey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
isArray := false
|
||||
var val interface{}
|
||||
switch filterValueField.Kind() {
|
||||
case reflect.Struct:
|
||||
qms = append(qms, whereJSON(table, column, filterValueField.Interface(), level+1)...)
|
||||
continue
|
||||
case reflect.Ptr:
|
||||
if !filterValueField.IsValid() || filterValueField.IsNil() {
|
||||
continue
|
||||
}
|
||||
if filterValueField.Elem().Kind() == reflect.Array ||
|
||||
filterValueField.Elem().Kind() == reflect.Slice {
|
||||
isArray = true
|
||||
}
|
||||
val = filterValueField.Elem().Interface()
|
||||
case reflect.Array,
|
||||
reflect.Slice:
|
||||
if !filterValueField.IsValid() || filterValueField.IsNil() {
|
||||
continue
|
||||
}
|
||||
isArray = true
|
||||
val = filterValueField.Interface()
|
||||
default:
|
||||
val = filterValueField.Interface()
|
||||
}
|
||||
|
||||
if isArray {
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->'%s' <@ to_jsonb(?::text[])", table, column, jsonkey), pq.Array(val)))
|
||||
} else {
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->>'%s' = ?", table, column, jsonkey), val))
|
||||
}
|
||||
}
|
||||
case reflect.String:
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s::text = ?", table, column), filter))
|
||||
default:
|
||||
panic(fmt.Errorf("invalid filter type %v", filterType.Kind()))
|
||||
}
|
||||
|
||||
return qms
|
||||
}
|
||||
205
internal/util/db/json_test.go
Normal file
205
internal/util/db/json_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWhereJSONStruct(t *testing.T) {
|
||||
age := 42
|
||||
filter := struct {
|
||||
First string `json:"firstName"`
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
Country string `json:"country"`
|
||||
City string
|
||||
Scopes []string `json:"scopes"`
|
||||
Age *int `json:"age"`
|
||||
Height *float32 `json:"height"`
|
||||
PhoneNumbers *[2]string `json:"phoneNumbers"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}{
|
||||
First: "Max",
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
PhoneNumbers: &[2]string{
|
||||
"+1 206 555 0100",
|
||||
"+44 113 496 0000",
|
||||
},
|
||||
}
|
||||
|
||||
sql, args := buildWhereJSONQuery(t, filter)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONStructComposition(t *testing.T) {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
sql, args := buildWhereJSONQuery(t, filter)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONString(t *testing.T) {
|
||||
sql, args := buildWhereJSONQuery(t, "https://example.org/users/123/profile")
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicEmptyResult(t *testing.T) {
|
||||
type privateName struct {
|
||||
First string `json:"firstName"`
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
}
|
||||
|
||||
filter := struct {
|
||||
privateName
|
||||
City string
|
||||
}{
|
||||
privateName: privateName{
|
||||
First: "Max",
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
City: "Vienna",
|
||||
}
|
||||
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", filter)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "filter resulted in empty query", panicFunc)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicInvalidFilterType(t *testing.T) {
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", 1)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "invalid filter type int", panicFunc)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicRecursion(t *testing.T) {
|
||||
type A struct {
|
||||
One string `json:"one"`
|
||||
}
|
||||
type B struct {
|
||||
A
|
||||
Two string `json:"two"`
|
||||
}
|
||||
type C struct {
|
||||
B
|
||||
Three string `json:"three"`
|
||||
}
|
||||
type D struct {
|
||||
C
|
||||
Four string `json:"four"`
|
||||
}
|
||||
type E struct {
|
||||
D
|
||||
Five string `json:"five"`
|
||||
}
|
||||
type F struct {
|
||||
E
|
||||
Six string `json:"six"`
|
||||
}
|
||||
type G struct {
|
||||
F
|
||||
Seven string `json:"seven"`
|
||||
}
|
||||
type H struct {
|
||||
G
|
||||
Eight string `json:"eight"`
|
||||
}
|
||||
type I struct {
|
||||
H
|
||||
Nine string `json:"nine"`
|
||||
}
|
||||
type J struct {
|
||||
I
|
||||
Ten string `json:"ten"`
|
||||
}
|
||||
|
||||
filter := struct {
|
||||
J
|
||||
Country string `json:"country"`
|
||||
}{
|
||||
J: J{
|
||||
I: I{
|
||||
H: H{
|
||||
G: G{
|
||||
F: F{
|
||||
E: E{
|
||||
D: D{
|
||||
C: C{
|
||||
B: B{
|
||||
A: A{
|
||||
One: "1",
|
||||
},
|
||||
Two: "2",
|
||||
},
|
||||
Three: "3",
|
||||
},
|
||||
Four: "4",
|
||||
},
|
||||
Five: "5",
|
||||
},
|
||||
Six: "6",
|
||||
},
|
||||
Seven: "7",
|
||||
},
|
||||
Eight: "8",
|
||||
},
|
||||
Nine: "9",
|
||||
},
|
||||
Ten: "10",
|
||||
},
|
||||
Country: "Austria",
|
||||
}
|
||||
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", filter)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "whereJSON reached maximum recursion (10/10)", panicFunc)
|
||||
}
|
||||
|
||||
func buildWhereJSONQuery(t *testing.T, filter interface{}) (string, []interface{}) {
|
||||
t.Helper()
|
||||
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
)
|
||||
|
||||
return queries.BuildQuery(query)
|
||||
}
|
||||
22
internal/util/db/or.go
Normal file
22
internal/util/db/or.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package db
|
||||
|
||||
import "github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
|
||||
// CombineWithOr receives a slice of query mods and returns a new slice with
|
||||
// a single query mod, combining all other query mods into an OR expression.
|
||||
func CombineWithOr(qms []qm.QueryMod) []qm.QueryMod {
|
||||
if len(qms) == 0 {
|
||||
return []qm.QueryMod{}
|
||||
}
|
||||
|
||||
if len(qms) == 1 {
|
||||
return qms
|
||||
}
|
||||
|
||||
q := []qm.QueryMod{qms[0]}
|
||||
for _, sq := range qms[1:] {
|
||||
q = append(q, qm.Or2(sq))
|
||||
}
|
||||
|
||||
return []qm.QueryMod{qm.Expr(q...)}
|
||||
}
|
||||
67
internal/util/db/or_test.go
Normal file
67
internal/util/db/or_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOr(t *testing.T) {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
qms := []qm.QueryMod{
|
||||
qm.Where("id = ?", 123),
|
||||
qm.Where("username = ?", "max.muster@example.org"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
}
|
||||
sql, args := buildOrQuery(t, qms)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestOrSingle(t *testing.T) {
|
||||
q := qm.Where("username = ?", "max.muster@example.org")
|
||||
qms := db.CombineWithOr([]qm.QueryMod{q})
|
||||
require.Len(t, qms, 1)
|
||||
assert.Equal(t, q, qms[0])
|
||||
}
|
||||
|
||||
func TestOrEmpty(t *testing.T) {
|
||||
qms := db.CombineWithOr([]qm.QueryMod{})
|
||||
assert.Empty(t, qms)
|
||||
|
||||
qms = db.CombineWithOr(nil)
|
||||
assert.Empty(t, qms)
|
||||
}
|
||||
|
||||
func buildOrQuery(t *testing.T, qms []qm.QueryMod) (string, []interface{}) {
|
||||
t.Helper()
|
||||
|
||||
o := db.CombineWithOr(qms)
|
||||
require.NotEmpty(t, o)
|
||||
|
||||
o = append(o, qm.Select("*"), qm.From("users"))
|
||||
q := models.NewQuery(o...)
|
||||
|
||||
return queries.BuildQuery(q)
|
||||
}
|
||||
32
internal/util/db/order_by.go
Normal file
32
internal/util/db/order_by.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
func OrderBy(orderDir types.OrderDir, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("%s %s", strings.Join(path, "."), strings.ToUpper(string(orderDir))))
|
||||
}
|
||||
|
||||
func OrderByLower(orderDir types.OrderDir, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s", strings.Join(path, "."), strings.ToUpper(string(orderDir))))
|
||||
}
|
||||
|
||||
type OrderByNulls string
|
||||
|
||||
const (
|
||||
OrderByNullsFirst OrderByNulls = "FIRST"
|
||||
OrderByNullsLast OrderByNulls = "LAST"
|
||||
)
|
||||
|
||||
func OrderByWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("%s %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls))
|
||||
}
|
||||
|
||||
func OrderByLowerWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls))
|
||||
}
|
||||
68
internal/util/db/order_by_test.go
Normal file
68
internal/util/db/order_by_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
swaggerTypes "allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/aarondl/sqlboiler/v4/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOrderBy(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
_, err := fix.UserRequiresConfirmation.Delete(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
|
||||
noUsername := models.User{
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
upperUsername := models.User{
|
||||
Username: null.StringFrom("USER3@example.com"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err = noUsername.Insert(ctx, sqlDB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = upperUsername.Insert(ctx, sqlDB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
users, err := models.Users(db.OrderBy(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, upperUsername.ID, users[0].ID)
|
||||
assert.Equal(t, upperUsername.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByLower(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, fix.User1.ID, users[0].ID)
|
||||
assert.Equal(t, fix.User1.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByWithNulls(swaggerTypes.OrderDirAsc, db.OrderByNullsFirst, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, noUsername.ID, users[0].ID)
|
||||
assert.Equal(t, noUsername.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByLowerWithNulls(swaggerTypes.OrderDirDesc, db.OrderByNullsLast, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, fix.UserDeactivated.ID, users[0].ID)
|
||||
assert.Equal(t, fix.UserDeactivated.Username, users[0].Username)
|
||||
assert.Equal(t, upperUsername.ID, users[1].ID)
|
||||
assert.Equal(t, upperUsername.Username, users[1].Username)
|
||||
})
|
||||
}
|
||||
17
internal/util/db/query_mods.go
Normal file
17
internal/util/db/query_mods.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
// QueryMods represents a slice of query mods, implementing the `queries.Applicator`
|
||||
// interface to allow for usage with eager loading methods of models. Unfortunately,
|
||||
// sqlboiler does not import the (identical) type used by the library, so we have to
|
||||
// declare and "implemented" it ourselves...
|
||||
type QueryMods []qm.QueryMod
|
||||
|
||||
// Apply applies the query mods to the query provided
|
||||
func (m QueryMods) Apply(q *queries.Query) {
|
||||
qm.Apply(q, m...)
|
||||
}
|
||||
28
internal/util/db/ts_vector.go
Normal file
28
internal/util/db/ts_vector.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
tsQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SearchStringToTSQuery returns a TSQuery string from user input.
|
||||
// The resulting query will match if every word matches a beginning of a word in the row.
|
||||
// This function will trim all leading and trailing as well as consecutive whitespaces and remove all single quotes before
|
||||
// transforming the input into TSQuery syntax.
|
||||
// If no input was given (nil or empty string) or the value only contains invalid characters, an empty string will be returned.
|
||||
func SearchStringToTSQuery(s *string) string {
|
||||
if s == nil || len(*s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
v := strings.TrimSpace(strings.ReplaceAll(*s, "'", ""))
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "'" + tsQueryWhiteSpaceRegex.ReplaceAllString(v, "':* & '") + "':*"
|
||||
}
|
||||
41
internal/util/db/ts_vector_test.go
Normal file
41
internal/util/db/ts_vector_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearchStringToTSQuery(t *testing.T) {
|
||||
expected := "'abcde':* & '12345':* & 'xyz':*"
|
||||
search := swag.String(" abcde 12345 xyz ")
|
||||
out := db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = "'abcde':*"
|
||||
search = swag.String("abcde")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = "'Hello':* & 'world':* & 'lorem':* & '12345':* & 'ipsum':* & 'abc':* & 'def':*"
|
||||
search = swag.String(" Hello world lorem 12345 ipsum abc def ")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = nil
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = swag.String("")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = swag.String(" '' ' ' ' ")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
25
internal/util/db/where_in.go
Normal file
25
internal/util/db/where_in.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// WhereIn was a copy from sqlboiler's WHERE IN query helpers since these don't get generated for nullable columns.
|
||||
// Since sqlboilers IN query helpers will set a param for earch element in the slice we reccomment using this packages IN.
|
||||
func WhereIn(tableName string, columnName string, slice []string) qm.QueryMod {
|
||||
return IN(fmt.Sprintf("%s.%s", tableName, columnName), slice)
|
||||
}
|
||||
|
||||
// IN is a replacement for sqlboilers IN query mod. sqlboilers IN will set a param for
|
||||
// each element in the slice and we do not recommend to use this, because it will run into driver and
|
||||
// database limits. While the sqlboiler IN fails at about ~10000 params this was tested with over 1000000.
|
||||
func IN(path string, slice []string) qm.QueryMod {
|
||||
return qm.Where(fmt.Sprintf("%s = any(?)", path), pq.StringArray(slice))
|
||||
}
|
||||
|
||||
func NIN(path string, slice []string) qm.QueryMod {
|
||||
return qm.Where(fmt.Sprintf("%s <> all(?)", path), pq.StringArray(slice))
|
||||
}
|
||||
39
internal/util/db/where_in_test.go
Normal file
39
internal/util/db/where_in_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
func TestWhereIn(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.WhereIn("app_user_profiles", "username", []string{"max", "muster", "peter"}),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestNIN(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.NIN("app_user_profiles.username", []string{"max", "muster", "peter"}),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user