Skip to content
nesymnoPublic

About

Helpers to use Bun more effective

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

bunutils

Go Version License

A collection of powerful helper functions and utilities for Bun ORM, designed to streamline database operations and reduce boilerplate code in Go applications.

Features

  • 🔍 Query Selectors: Composable query builders with type-safe WHERE conditions
  • 🔄 Transaction Management: Context-aware transaction handling with nested transaction support
  • 📊 Filtering Utilities: Pre-built Where struct for common filtering patterns
  • 🏗️ Querier Interface: Context-aware query builders that automatically use transactions from context
  • ❌ Error Handling: Specialized error checkers for common database errors

Database Compatibility

This library works with all databases supported by Bun (PostgreSQL, MySQL, SQLite, MSSQL). However, some features are PostgreSQL-specific:

  • JSONB Selectors: WhereJsonbEqual, WhereJsonbPathEqual, WhereJsonbObjectsArrayKeyValueEqual, WhereJsonbPathObjectsArrayKeyValueEqual - require PostgreSQL's JSONB support
  • Case-insensitive string matching: WhereContains, WhereBegins, WhereEnds - use PostgreSQL's ILIKE operator
  • DISTINCT ON: WhereDistinctOn - uses PostgreSQL's DISTINCT ON clause

All other features (transactions, basic selectors, querier interface, error handling, etc.) are database-agnostic and work across all supported databases.

TODO

  • Check versions less then 1.25 (1.23+, 1.24+)

Installation

go get github.com/nesymno/bunutils

Quick Start

package main

import (
    "context"
    "database/sql"
    
    "github.com/nesymno/bunutils"
    "github.com/uptrace/bun"
    "github.com/uptrace/bun/dialect/pgdialect"
    "github.com/uptrace/bun/driver/pgdriver"
)

func main() {
    // Setup Bun
    sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN("postgres://...")))
    db := bun.NewDB(sqldb, pgdialect.New())
    defer db.Close()
    
    ctx := context.Background()
    
    // Use selectors
    var users []User
    err := db.NewSelect().
        Model(&users).
        Apply(bunutils.WhereEqual("status", "active")).
        Apply(bunutils.WhereContains("email", "example.com")).
        Scan(ctx)
}

Documentation

1. Query Selectors

Build complex queries using composable selector functions:

Basic Selectors

// Equality checks
query.Apply(bunutils.WhereEqual("status", "active"))
query.Apply(bunutils.WhereNotEqual("role", "guest"))

// NULL checks
query.Apply(bunutils.WhereNull("deleted_at"))
query.Apply(bunutils.WhereNotNull("verified_at"))

// IN clauses
query.Apply(bunutils.WhereIn("id", []string{"1", "2", "3"}))
query.Apply(bunutils.WhereNotIn("status", []string{"banned", "suspended"}))

// String matching (case-insensitive, PostgreSQL ILIKE operator)
query.Apply(bunutils.WhereContains("name", "john"))  // ILIKE '%john%'
query.Apply(bunutils.WhereBegins("email", "admin")) // ILIKE 'admin%'
query.Apply(bunutils.WhereEnds("domain", ".com"))   // ILIKE '%.com'

// Time-based queries
query.Apply(bunutils.WhereBefore("created_at", time.Now()))
query.Apply(bunutils.WhereAfter("updated_at", lastWeek))

JSONB Selectors

Note: PostgreSQL only

Work with PostgreSQL JSONB columns safely:

// Simple JSONB field equality
query.Apply(bunutils.WhereJsonbEqual("metadata", "status", "verified"))
// Generates: metadata->>'status' = 'verified'

// Nested JSONB path equality
query.Apply(bunutils.WhereJsonbPathEqual("data", []string{"user", "profile", "name"}, "John"))
// Generates: data->'user'->'profile'->>'name' = 'John'

// JSONB array of objects - find object with matching field
query.Apply(bunutils.WhereJsonbObjectsArrayKeyValueEqual("tags", "items", "id", "123"))

// Nested array search
query.Apply(bunutils.WhereJsonbPathObjectsArrayKeyValueEqual(
    "metadata", 
    []string{"users", "preferences"}, 
    "theme", 
    "dark",
))

Combining Selectors

// Apply multiple conditions
query.Apply(
    bunutils.WhereEqual("status", "active"),
    bunutils.WhereNotNull("verified_at"),
)

// Conditional application
isAdmin := true
query.Apply(bunutils.ApplyIf(isAdmin, 
    bunutils.WhereEqual("role", "admin"),
))

// OR groups
query.Apply(bunutils.OrGroup(
    bunutils.WhereEqual("status", "active"),
    bunutils.WhereEqual("status", "pending"),
))

// AND groups
query.Apply(bunutils.AndGroup(
    bunutils.WhereEqual("verified", true),
    bunutils.WhereNotNull("email"),
))

// Complex OR conditions
query.Apply(bunutils.Or(
    bunutils.WhereEqual("role", "admin"),
    bunutils.WhereEqual("role", "moderator"),
    bunutils.WhereEqual("role", "editor"),
))

2. Transaction Management

Simple Transactions with InTx

The InTx function handles transaction lifecycle automatically:

err := bunutils.InTx(ctx, db, func(ctx context.Context) error {
    // Create user
    _, err := db.NewInsert().
        Model(&user).
        Exec(ctx)  // Automatically uses transaction
    if err != nil {
        return err // Transaction will rollback
    }
    
    // Create profile
    _, err = db.NewInsert().
        Model(&profile).
        Exec(ctx)
    if err != nil {
        return err // Transaction will rollback
    }
    
    return nil // Transaction will commit
})

Nested Transactions

InTx supports nested calls - only the outermost call creates the transaction:

func CreateUser(ctx context.Context, db *bun.DB, user *User) error {
    return bunutils.InTx(ctx, db, func(ctx context.Context) error {
        // This might create a new transaction OR use existing one
        _, err := db.NewInsert().Model(user).Exec(ctx)
        return err
    })
}

func CreateUserWithProfile(ctx context.Context, db *bun.DB, user *User, profile *Profile) error {
    return bunutils.InTx(ctx, db, func(ctx context.Context) error {
        // Outer transaction
        if err := CreateUser(ctx, db, user); err != nil {
            return err // Nested call uses same transaction
        }
        
        _, err := db.NewInsert().Model(profile).Exec(ctx)
        return err
    })
}

Manual Transaction Management

For more control, use TxToContext and TxFromContext:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

// Store transaction in context
ctx = bunutils.TxToContext(ctx, &tx)

// Pass context to functions
err = createUser(ctx, db)
if err != nil {
    return err
}

return tx.Commit()

3. Querier Interface

The Querier interface provides context-aware query builders:

type UserRepository struct {
    querier bunutils.Querier
}

func NewUserRepository(db *bun.DB) *UserRepository {
    return &UserRepository{
        querier: bunutils.NewQuerier(db),
    }
}

func (r *UserRepository) GetByID(ctx context.Context, id string) (*User, error) {
    user := new(User)
    
    // Automatically uses transaction from context if available
    err := r.querier.NewSelectQuery(ctx).
        Model(user).
        Where("id = ?", id).
        Scan(ctx)
    
    if bunutils.IsNotFoundError(err) {
        return nil, fmt.Errorf("user not found")
    }
    
    return user, err
}

func (r *UserRepository) Create(ctx context.Context, user *User) error {
    _, err := r.querier.NewInsertQuery(ctx).
        Model(user).
        Exec(ctx)
    
    if bunutils.IsConstraintError(err) {
        return fmt.Errorf("user already exists")
    }
    
    return err
}

4. Where Struct - Advanced Filtering

Use the pre-built Where struct for common filtering patterns:

// Build complex filters
where := &bunutils.Where{
    IDs: []string{"1", "2", "3"},
    NotInIDs: []string{"99"},
    OnlyDeleted: false,
    WithDeleted: false,
    Limit: ToPtr(10),
    Offset: ToPtr(0),
    CreatedAfter: ToPtr(time.Now().Add(-24*time.Hour).UnixMilli()),
    SelectColumns: []string{"id", "name", "email"},
    SortBy: 1,
    SortDesc: true,
}

// Define sort mapping
where.Order = bunutils.Order{
    1: "created_at",
    2: "updated_at",
    3: "name",
}

// Apply to query
query := db.NewSelect().Model(&users)
query = where.Where(query)   // Apply WHERE conditions
query = where.Select(query)  // Apply SELECT, LIMIT, ORDER BY

Bitwise Flag Filtering

const (
    FlagActive   = 1 << 0  // 1
    FlagVerified = 1 << 1  // 2
    FlagPremium  = 1 << 2  // 4
)

where := &bunutils.Where{
    HasFlags: []int{FlagActive, FlagVerified},    // Must have both flags
    HasNotFlags: []int{FlagPremium},              // Must not have flag
}

query := db.NewSelect().Model(&users)
query = where.Where(query)

Use as Selector

where := &bunutils.Where{IDs: []string{"1", "2"}}

query := db.NewSelect().
    Model(&users).
    Apply(bunutils.UseWhere(where)).
    Apply(bunutils.WhereEqual("status", "active"))

5. Error Handling

Specialized error handlers for common database scenarios:

user := new(User)
err := db.NewSelect().Model(user).Where("id = ?", id).Scan(ctx)

if bunutils.IsNotFoundError(err) {
    return fmt.Errorf("user not found")
}

// Check constraint violations
_, err = db.NewInsert().Model(user).Exec(ctx)

if bunutils.IsConstraintError(err) {
    return fmt.Errorf("user with this email already exists")
}

Complete Example

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"

    "github.com/nesymno/bunutils"
    "github.com/uptrace/bun"
    "github.com/uptrace/bun/dialect/pgdialect"
    "github.com/uptrace/bun/driver/pgdriver"
)

type User struct {
    bun.BaseModel `bun:"table:users"`
    
    ID        string    `bun:"id,pk"`
    Email     string    `bun:"email,notnull,unique"`
    Name      string    `bun:"name"`
    Status    string    `bun:"status"`
    Role      string    `bun:"role"`
    Flags     int       `bun:"flags"`
    CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"`
}

// Custom Where struct that embeds bunutils.Where
// This allows you to add domain-specific filtering while reusing common filters
type UserWhere struct {
    bunutils.Where                          // Embed base Where for common filters
    
    Status        string   `json:"status,omitempty" form:"status"`
    Roles         []string `json:"roles,omitempty" form:"roles"`
    EmailContains string   `json:"email_contains,omitempty" form:"email_contains"`
    IsVerified    *bool    `json:"is_verified,omitempty" form:"is_verified"`
}

// Apply custom WHERE conditions, then delegate to base Where
func (w *UserWhere) ApplyFilters(q *bun.SelectQuery) *bun.SelectQuery {
    if w == nil {
        return q
    }
    
    // Apply custom domain-specific filters
    if w.Status != "" {
        q = q.Where("?TableAlias.status = ?", w.Status)
    }
    if len(w.Roles) > 0 {
        q = q.Where("?TableAlias.role IN (?)", bun.In(w.Roles))
    }
    if w.EmailContains != "" {
        q = q.Where("?TableAlias.email ILIKE ?", "%"+w.EmailContains+"%")
    }
    if w.IsVerified != nil {
        if *w.IsVerified {
            q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
                return q.Where("?TableAlias.flags & ? = ?", 1, 1)
            })
        } else {
            q = q.Where("?TableAlias.flags & ? = 0", 1)
        }
    }
    
    // Apply base filters (IDs, flags, dates, soft deletes, etc.)
    q = w.Where.Where(q)
    
    return q
}

type UserRepository struct {
    querier bunutils.Querier
}

func NewUserRepository(db *bun.DB) *UserRepository {
    return &UserRepository{
        querier: bunutils.NewQuerier(db),
    }
}

func (r *UserRepository) Find(ctx context.Context, where *UserWhere) ([]*User, error) {
    var users []*User
    
    q := r.querier.NewSelectQuery(ctx).Model(&users)
    
    // Apply custom filters
    q = where.ApplyFilters(q)
    
    // Apply SELECT, LIMIT, OFFSET, ORDER BY from base Where
    q = where.Select(q)
    
    err := q.Scan(ctx)
    return users, err
}

func (r *UserRepository) Create(ctx context.Context, user *User) error {
    _, err := r.querier.NewInsertQuery(ctx).
        Model(user).
        Exec(ctx)
    
    if bunutils.IsConstraintError(err) {
        return fmt.Errorf("user already exists")
    }
    
    return err
}

func ToPtr[T any](v T) *T {
    return &v
}

func main() {
    // Setup database
    dsn := "postgres://user:pass@localhost:5432/dbname?sslmode=disable"
    sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
    db := bun.NewDB(sqldb, pgdialect.New())
    defer db.Close()
    
    repo := NewUserRepository(db)
    ctx := context.Background()
    
    // Example 1: Using custom UserWhere with both custom and base filters
    where := &UserWhere{
        // Custom filters
        Status:        "active",
        Roles:         []string{"admin", "moderator"},
        EmailContains: "example.com",
        IsVerified:    ToPtr(true),
        
        // Base filters from bunutils.Where
        Where: bunutils.Where{
            IDs:          []string{"1", "2", "3"},
            CreatedAfter: ToPtr(time.Now().Add(-7*24*time.Hour).UnixMilli()),
            Limit:        ToPtr(10),
            Offset:       ToPtr(0),
            SortBy:       1,
            SortDesc:     true,
            Order: bunutils.Order{
                1: "created_at",
                2: "email",
                3: "name",
            },
        },
    }
    
    users, err := repo.Find(ctx, where)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Found %d users matching filters\n", len(users))
    
    // Example 2: Transaction with InTx
    err = bunutils.InTx(ctx, db, func(ctx context.Context) error {
        user := &User{
            ID:     "123",
            Email:  "test@example.com",
            Name:   "Test User",
            Status: "active",
            Role:   "user",
        }
        
        return repo.Create(ctx, user)
    })
    
    if err != nil {
        log.Fatal(err)
    }
    
    // Example 3: Using base bunutils.Where directly
    baseWhere := &bunutils.Where{
        IDs:   []string{"1", "2", "3"},
        Limit: ToPtr(10),
        Order: bunutils.Order{1: "created_at"},
        SortBy:   1,
        SortDesc: true,
    }
    
    var moreUsers []*User
    query := db.NewSelect().Model(&moreUsers)
    query = baseWhere.Where(query)
    query = baseWhere.Select(query)
    
    if err := query.Scan(ctx); err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Found %d users using base Where\n", len(moreUsers))
}

API Reference

Selectors

  • Apply(selectors ...Selector) Selector - Combine multiple selectors
  • ApplyIf(cond bool, selectors ...Selector) Selector - Conditionally apply selectors
  • OrGroup(selectors ...Selector) Selector - Create OR group
  • AndGroup(selectors ...Selector) Selector - Create AND group
  • Or(selectors ...Selector) Selector - Separate conditions with OR
  • UseWhere(where *Where) Selector - Use Where struct as selector
  • WhereEqual(col string, value any) Selector
  • WhereNotEqual(col string, value any) Selector
  • WhereNull(col string) Selector
  • WhereNotNull(col string) Selector
  • WhereIn(col string, values any) Selector
  • WhereNotIn(col string, values any) Selector
  • WhereContains(col string, substr string) Selector (PostgreSQL ILIKE)
  • WhereBegins(col string, substr string) Selector (PostgreSQL ILIKE)
  • WhereEnds(col string, substr string) Selector (PostgreSQL ILIKE)
  • WhereBefore(col string, t time.Time) Selector
  • WhereAfter(col string, t time.Time) Selector
  • WhereDistinctOn(col string) Selector (PostgreSQL only)
  • WhereJsonbEqual(col string, field string, value any) Selector (PostgreSQL only)
  • WhereJsonbPathEqual(col string, path []string, value any) Selector (PostgreSQL only)
  • WhereJsonbObjectsArrayKeyValueEqual(col string, key, field string, value any) Selector (PostgreSQL only)
  • WhereJsonbPathObjectsArrayKeyValueEqual(col string, path []string, field string, value any) Selector (PostgreSQL only)

Transaction Context

  • InTx(ctx context.Context, client *bun.DB, fn func(ctx context.Context) error) error - Execute function in transaction
  • TxToContext(ctx context.Context, tx *bun.Tx) context.Context - Store transaction in context
  • TxFromContext(ctx context.Context) *bun.Tx - Retrieve transaction from context

Error Handling

  • IsNotFoundError(err error) bool - Check if error is sql.ErrNoRows
  • IsConstraintError(err error) bool - Check for unique constraint violations

Querier Interface

  • NewQuerier(c *bun.DB) Querier - Create new querier
  • NewSelectQuery(ctx context.Context) *bun.SelectQuery - Get context-aware SELECT query
  • NewInsertQuery(ctx context.Context) *bun.InsertQuery - Get context-aware INSERT query
  • NewUpdateQuery(ctx context.Context) *bun.UpdateQuery - Get context-aware UPDATE query
  • NewDeleteQuery(ctx context.Context) *bun.DeleteQuery - Get context-aware DELETE query

Utilities

  • OrderAsc(col string) string - Create ascending order expression
  • OrderDesc(col string) string - Create descending order expression

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Changeset Workflow

This project uses a changeset-based workflow for versioning and releases, similar to npm changesets.

Adding a Changeset

When you make changes that should be released, add a changeset:

just changeset

This will:

  1. Prompt you for the type of change (major/minor/patch)
  2. Ask for a description of your changes
  3. Create a changeset file in .changeset/

Include this changeset file in your Pull Request.

Changeset Types

  • patch - Bug fixes and minor changes (0.0.X)
  • minor - New features, backwards compatible (0.X.0)
  • major - Breaking changes (X.0.0)

Checking Next Version

To see what version will be released based on current changesets:

just version

Creating a Release

When ready to release, run:

just release

This will:

  1. Calculate the next version based on changesets
  2. Update CHANGELOG.md with changes
  3. Remove processed changeset files
  4. Commit and push changes
  5. Create and push a version tag
  6. Trigger the GitHub Actions release workflow

License

This project is licensed under the MIT License - see the LICENSE file for details.

Acknowledgments

  • Built on top of the excellent Bun ORM
  • Ukrainian Golang Community @uagolang
  • Kuberly.io - your devs should work on product, not on infra

About

Helpers to use Bun more effective

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

⌂ 换网址