(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

109
internal/util/db/db.go Normal file
View 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
View 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)
}

View 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
View 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")
}

View 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
View 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)
}

View 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
View 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
}

View 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
View 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...)}
}

View 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)
}

View 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))
}

View 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)
})
}

View 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...)
}

View 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, "':* & '") + "':*"
}

View 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)
}

View 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))
}

View 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...)
}