Skip to main content

ORM: Querying

VEF wraps Bun into a typed, fluent query builder API that provides type-safe SQL construction, automatic audit field handling, and cross-database dialect support. All query builders are accessed through orm.DB.

This page covers reading data: SELECT clauses, conditions, joins, ordering, pagination, grouping, locking, execution, and query composition. Sibling pages cover expressions and aggregates, mutations, and DDL and the public surface map.

API Surface Policy​

The ORM package exposes two audited surfaces. VEF-owned ORM method families are documented by receiver/category on this page and in the model/transaction guides; every exact method signature is listed in the public API index. These are the orm.SelectQuery, orm.InsertQuery, orm.UpdateQuery, orm.DeleteQuery, orm.MergeQuery, DDL, condition, expression, aggregate, and window-builder contracts used by application code.

The Bun pass-through surface consists of aliases such as orm.BunSelectQuery, orm.BunInsertQuery, orm.BunUpdateQuery, and orm.BunDeleteQuery; those methods follow upstream github.com/uptrace/bun behavior at the pinned source dependency version v1.2.18. Bun/schema aliases such as Table, Field, Relation, and Dialect follow the same pass-through policy. Do not read VEF query-interface behavior from the Bun aliases: for example, VEF orm.SelectQuery.Count returns int64, while the upstream Bun alias method signatures are tracked separately in the public API index. Query interfaces that embed fmt.Stringer expose String() for SQL/debug rendering; exact signatures are tracked in the public API index.

Overview​

CategoryBuilderDescription
SELECTdb.NewSelect()Query data
INSERTdb.NewInsert()Create records
UPDATEdb.NewUpdate()Modify records
DELETEdb.NewDelete()Delete records
MERGEdb.NewMerge()Upsert operations
Raw SQLdb.NewRaw(query, args...)Execute raw SQL

Getting Started​

All query operations start from orm.DB, which you receive via dependency injection:

func NewUserService(db orm.DB) *UserService {
return &UserService{db: db}
}

SELECT Clause​

Basic Select​

// SELECT all columns from users
var users []User
err := db.NewSelect().
Model(&users).
Scan(ctx)

Selecting Specific Columns​

// SELECT su.id, su.username FROM sys_user AS su
var users []User
err := db.NewSelect().
Model(&users).
Select("id", "username").
Scan(ctx)

Column Alias​

// SELECT su.username AS name FROM sys_user AS su
var users []User
err := db.NewSelect().
Model(&users).
SelectAs("username", "name").
Scan(ctx)

Excluding Columns​

// Select all columns except password
var users []User
err := db.NewSelect().
Model(&users).
Exclude("password").
Scan(ctx)

Expression Columns​

// SELECT ..., UPPER(su.username) AS upper_name
var results []struct {
User
UpperName string `bun:"upper_name"`
}
err := db.NewSelect().
Model(&results).
SelectExpr(func(eb orm.ExprBuilder) any {
return eb.Upper(eb.Column("username"))
}, "upper_name").
Scan(ctx)

Select Model Columns / PKs​

// Select only the model's declared columns (no extra expressions)
db.NewSelect().Model(&users).SelectModelColumns()

// Select only primary key columns
db.NewSelect().Model(&users).SelectModelPKs()

Distinct​

// SELECT DISTINCT su.department_id FROM sys_user AS su
db.NewSelect().
Model(&users).
Select("department_id").
Distinct().
Scan(ctx)

// PostgreSQL DISTINCT ON
db.NewSelect().
Model(&users).
DistinctOnColumns("department_id").
Scan(ctx)

WHERE Conditions​

The Where method takes a callback with a ConditionBuilder that provides type-safe condition construction.

Equality​

// WHERE su.is_active = TRUE
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.IsTrue("is_active")
}).Scan(ctx)

// WHERE su.username = 'admin'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Equals("username", "admin")
}).Scan(ctx)

Comparison Operators​

// WHERE su.age > 18 AND su.age <= 65
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.GreaterThan("age", 18).
LessThanOrEqual("age", 65)
}).Scan(ctx)

BETWEEN​

// WHERE su.created_at BETWEEN '2024-01-01' AND '2024-12-31'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Between("created_at", startDate, endDate)
}).Scan(ctx)

IN / NOT IN​

// WHERE su.status IN ('active', 'pending')
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.In("status", []string{"active", "pending"})
}).Scan(ctx)

// WHERE su.id NOT IN (SELECT ... )
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.NotInSubQuery("id", func(sq orm.SelectQuery) {
sq.Model((*BlockedUser)(nil)).Select("user_id")
})
}).Scan(ctx)

NULL Checks​

// WHERE su.deleted_at IS NULL
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.IsNull("deleted_at")
}).Scan(ctx)

// WHERE su.email IS NOT NULL
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.IsNotNull("email")
}).Scan(ctx)

String Matching (LIKE)​

// WHERE su.username LIKE '%admin%'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Contains("username", "admin")
}).Scan(ctx)

// WHERE su.email LIKE 'test%'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.StartsWith("email", "test")
}).Scan(ctx)

// WHERE su.name LIKE '%son'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.EndsWith("name", "son")
}).Scan(ctx)

// Case-insensitive: WHERE LOWER(su.email) LIKE LOWER('%Test%')
// (PostgreSQL uses ILIKE automatically)
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.ContainsIgnoreCase("email", "Test")
}).Scan(ctx)

// Match any of multiple values
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.ContainsAny("name", []string{"John", "Jane"})
}).Scan(ctx)

OR Conditions​

Every condition method has an Or prefix variant:

// WHERE su.status = 'active' OR su.status = 'pending'
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Equals("status", "active").
OrEquals("status", "pending")
}).Scan(ctx)

Grouped Conditions (Parentheses)​

// WHERE (su.role = 'admin' OR su.role = 'super_admin') AND su.is_active = TRUE
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Group(func(inner orm.ConditionBuilder) {
inner.Equals("role", "admin").
OrEquals("role", "super_admin")
}).
IsTrue("is_active")
}).Scan(ctx)

Column-to-Column Comparison​

// WHERE su.created_at <> su.updated_at
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.NotEqualsColumn("created_at", "updated_at")
}).Scan(ctx)

Subquery Conditions​

// WHERE su.department_id = (SELECT id FROM departments WHERE code = 'IT')
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.EqualsSubQuery("department_id", func(sq orm.SelectQuery) {
sq.Model((*Department)(nil)).
Select("id").
Where(func(inner orm.ConditionBuilder) {
inner.Equals("code", "IT")
})
})
}).Scan(ctx)

// WHERE su.salary > ALL (SELECT salary FROM ...)
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.GreaterThanAll("salary", func(sq orm.SelectQuery) {
sq.Model((*Employee)(nil)).Select("salary").
Where(func(inner orm.ConditionBuilder) {
inner.Equals("department_id", deptID)
})
})
}).Scan(ctx)

Expression Conditions​

// WHERE EXTRACT(YEAR FROM su.created_at) = 2024
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.Expr(func(eb orm.ExprBuilder) any {
return eb.Equals(
eb.ExtractYear(eb.Column("created_at")),
2024,
)
})
}).Scan(ctx)

Audit Condition Shortcuts​

// WHERE su.created_by = ? (current user from context)
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.CreatedByEqualsCurrent()
}).Scan(ctx)

// WHERE su.created_at BETWEEN ? AND ?
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.CreatedAtBetween(startTime, endTime)
}).Scan(ctx)

// WHERE su.updated_by IN ('user1', 'user2')
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.UpdatedByIn([]string{"user1", "user2"})
}).Scan(ctx)

Primary Key Shortcuts​

// WHERE su.id = ?
db.NewSelect().Model(&user).
Where(func(cb orm.ConditionBuilder) {
cb.PKEquals(userID)
}).Scan(ctx)

// WHERE su.id IN (?, ?, ?)
db.NewSelect().Model(&users).
Where(func(cb orm.ConditionBuilder) {
cb.PKIn([]string{id1, id2, id3})
}).Scan(ctx)

JOIN Operations​

VEF supports multiple join strategies, each with 4 source variants: Model, Table name, SubQuery, and Expression.

Join by Model​

// INNER JOIN sys_department AS sd ON su.department_id = sd.id
db.NewSelect().Model(&users).
Join((*Department)(nil), func(cb orm.ConditionBuilder) {
cb.EqualsColumn("su.department_id", "sd.id")
}).Scan(ctx)

Left Join​

// LEFT JOIN sys_department AS sd ON su.department_id = sd.id
db.NewSelect().Model(&users).
LeftJoin((*Department)(nil), func(cb orm.ConditionBuilder) {
cb.EqualsColumn("su.department_id", "sd.id")
}).Scan(ctx)

Join with Custom Alias​

// LEFT JOIN sys_department AS dept ON su.department_id = dept.id
db.NewSelect().Model(&users).
LeftJoin((*Department)(nil), func(cb orm.ConditionBuilder) {
cb.EqualsColumn("su.department_id", "dept.id")
}, "dept").Scan(ctx)

Join by Table Name​

// LEFT JOIN departments AS d ON su.department_id = d.id
db.NewSelect().Model(&users).
LeftJoinTable("departments", func(cb orm.ConditionBuilder) {
cb.EqualsColumn("su.department_id", "d.id")
}, "d").Scan(ctx)

Join with SubQuery​

// INNER JOIN (SELECT department_id, COUNT(*) AS cnt FROM ...) AS dept_stats
// ON su.department_id = dept_stats.department_id
db.NewSelect().Model(&users).
JoinSubQuery(
func(sq orm.SelectQuery) {
sq.Model((*User)(nil)).
Select("department_id").
SelectExpr(func(eb orm.ExprBuilder) any {
return eb.CountAll()
}, "cnt").
GroupBy("department_id")
},
func(cb orm.ConditionBuilder) {
cb.EqualsColumn("su.department_id", "dept_stats.department_id")
},
"dept_stats",
).Scan(ctx)

JoinRelations (Declarative)​

For common foreign-key JOINs, JoinRelations provides a declarative approach:

// Automatically resolves: LEFT JOIN sys_department AS sd ON su.department_id = sd.id
db.NewSelect().Model(&users).
JoinRelations(&orm.RelationSpec{
Model: (*Department)(nil),
SelectedColumns: []orm.ColumnInfo{
{Name: "name", Alias: "department_name"},
},
}).Scan(ctx)

RelationSpec fields:

  • Model: the related model (required)
  • Alias: custom table alias (default: model's default alias)
  • JoinType: orm.JoinDefault (defaults to LEFT JOIN), orm.JoinLeft, orm.JoinInner, orm.JoinRight, orm.JoinFull, orm.JoinCross
  • ForeignColumn: auto-resolved to {model_name}_{pk} if empty
  • ReferencedColumn: auto-resolved to PK if empty
  • SelectedColumns: which columns to select with aliasing
  • On: additional JOIN conditions

Bun Relations​

// Load Bun-defined relations
db.NewSelect().Model(&users).
Relation("Department").
Relation("Roles", func(sq orm.SelectQuery) {
sq.Where(func(cb orm.ConditionBuilder) {
cb.IsTrue("is_active")
})
}).Scan(ctx)

Ordering​

// ORDER BY su.created_at ASC
db.NewSelect().Model(&users).
OrderBy("created_at").Scan(ctx)

// ORDER BY su.created_at DESC
db.NewSelect().Model(&users).
OrderByDesc("created_at").Scan(ctx)

// ORDER BY CASE ... END (expression-based)
db.NewSelect().Model(&users).
OrderByExpr(func(eb orm.ExprBuilder) any {
return eb.Case(func(cb orm.CaseBuilder) {
cb.When(func(c orm.ConditionBuilder) { c.Equals("status", "active") }).Then(1).
When(func(c orm.ConditionBuilder) { c.Equals("status", "pending") }).Then(2).
Else(3)
})
}).Scan(ctx)

Pagination​

// Simple LIMIT/OFFSET
db.NewSelect().Model(&users).
Limit(20).Offset(40).Scan(ctx)

// Using page.Pageable (framework convention)
p := page.Pageable{Page: 3, Size: 20}
db.NewSelect().Model(&users).
Paginate(p).Scan(ctx)

// ScanAndCount: fetch page data + total count in one call
total, err := db.NewSelect().Model(&users).
Paginate(p).
ScanAndCount(ctx)

GROUP BY & HAVING​

// SELECT department_id, COUNT(*) AS cnt
// FROM sys_user GROUP BY department_id HAVING COUNT(*) > 5
type DeptCount struct {
DepartmentID string `bun:"department_id"`
Cnt int64 `bun:"cnt"`
}
var results []DeptCount
err := db.NewSelect().
Model((*User)(nil)).
Select("department_id").
SelectExpr(func(eb orm.ExprBuilder) any {
return eb.CountAll()
}, "cnt").
GroupBy("department_id").
Having(func(cb orm.ConditionBuilder) {
cb.Expr(func(eb orm.ExprBuilder) any {
return eb.GreaterThan(eb.CountAll(), 5)
})
}).Scan(ctx, &results)

Row-Level Locking​

// SELECT ... FOR UPDATE
db.NewSelect().Model(&user).
Where(func(cb orm.ConditionBuilder) { cb.PKEquals(id) }).
ForUpdate().
Scan(ctx)

// FOR UPDATE NOWAIT
db.NewSelect().Model(&user).ForUpdateNoWait().Scan(ctx)

// FOR UPDATE SKIP LOCKED (useful for job queues)
db.NewSelect().Model(&tasks).
Where(func(cb orm.ConditionBuilder) {
cb.Equals("status", "pending")
}).
Limit(10).
ForUpdateSkipLocked().
Scan(ctx)

// FOR SHARE
db.NewSelect().Model(&user).ForShare().Scan(ctx)

// FOR SHARE NOWAIT / SKIP LOCKED
db.NewSelect().Model(&user).ForShareNoWait().Scan(ctx)
db.NewSelect().Model(&user).ForShareSkipLocked().Scan(ctx)

// FOR KEY SHARE / FOR NO KEY UPDATE (PostgreSQL only)
db.NewSelect().Model(&user).ForKeyShare().Scan(ctx)
db.NewSelect().Model(&user).ForNoKeyUpdate().Scan(ctx)

// FOR NO KEY UPDATE NOWAIT / SKIP LOCKED (PostgreSQL only)
db.NewSelect().Model(&user).ForNoKeyUpdateNoWait().Scan(ctx)
db.NewSelect().Model(&user).ForNoKeyUpdateSkipLocked().Scan(ctx)

Dialect support: SQLite does not support row-level locking — calls are silently ignored with a warning. FOR NO KEY UPDATE and FOR KEY SHARE are PostgreSQL-only; on MySQL they are silently ignored with a warning. Each lock mode also has NoWait and SkipLocked variants. Each dialect-and-lock-mode combination warns once per process to avoid flooding the log of a loop that locks rows.

Execution Methods​

These rows describe VEF orm.SelectQuery execution methods, not the upstream orm.BunSelectQuery pass-through alias.

MethodReturnsPurpose
Scan(ctx, dest...)errorScan rows into model or dest
Exec(ctx, dest...)sql.Result, errorExecute without scan
Rows(ctx)*sql.Rows, errorGet raw rows iterator
ScanAndCount(ctx)int64, errorScan + count total (for pagination)
Count(ctx)int64, errorCount only
Exists(ctx)bool, errorCheck existence

Query Composition with Apply​

The Apply / ApplyIf pattern enables reusable query fragments:

// Define reusable conditions
func ActiveOnly(q orm.SelectQuery) {
q.Where(func(cb orm.ConditionBuilder) {
cb.IsTrue("is_active")
})
}

func CreatedAfter(t time.Time) orm.ApplyFunc[orm.SelectQuery] {
return func(q orm.SelectQuery) {
q.Where(func(cb orm.ConditionBuilder) {
cb.CreatedAtGreaterThanOrEqual(t)
})
}
}

// Use them
db.NewSelect().Model(&users).
Apply(ActiveOnly, CreatedAfter(lastMonth)).
Scan(ctx)

// Conditional application
db.NewSelect().Model(&users).
ApplyIf(keyword != "", func(q orm.SelectQuery) {
q.Where(func(cb orm.ConditionBuilder) {
cb.Contains("username", keyword)
})
}).
Scan(ctx)

Quiet SQL Logging​

The orm.DB SQL query hook demotes successful statement logs from Info to Debug when the context carries a quiet-SQL-log mark. Framework maintenance loops (the durable cron engine's claim/sweep polling) run their periodic queries under this mark so steady-state logs stay readable; host applications may mark their own polling work the same way.

HelperContract
IsQuietSQLLog(ctx)reports whether the context carries the quiet-SQL-log mark
WithQuietSQLLog(ctx)marks the context so the query hook demotes successful statement logs to Debug
WithoutQuietSQLLog(ctx)lifts the mark; a framework polling loop marks its own bookkeeping quiet, but the mark must not survive the handoff to host code

Slow-query warnings and failures keep their level — noise reduction must never hide a problem.

Next Step​

  • ORM: Expressions & Aggregates — the expression builder, aggregate and window functions, CTEs, and set operations
  • ORM: Mutations — INSERT, UPDATE, DELETE, raw queries, and soft-delete behavior
  • Generic CRUD — higher-level CRUD operations built on top of the query builder