English | 简体中文
gooq is a type-safe SQL query DSL for GoFrame, inspired by jOOQ.
- Type-safe —
Field[T]carries the column type at compile time:User.Age.Gt(18)compiles,User.Age.Gt("x")doesn't. - Offline rendering —
ToSql(dialect)produces SQL + args anywhere, without a database connection; execute with the standard library orgooq/db. - Dialect aware — MySQL / MariaDB / TiDB / OceanBase / PostgreSQL / GaussDB / Dameng / SQLite each implement
DialectSpec. A new dialect registers the whole spec. Connection drivers register separately throughdb.Register. - Zero runtime guessing — soft-delete conditions and the full column list come from static
TableMetawritten at codegen time. INSERT and UPDATE write only columns that were set. - Layered packages —
gooq(core: fields, tables, expression language, dialects, render contract),gooq/db(connections, transactions, scanning),gooq/dsl(query and DML builders),gooq/fn(function library); generated table code only importsgooq.
All rendered output shown below comes from test assertions — copy-paste friendly.
- Features
- Quick Start
- Select
- DML
- Execution
- Configuration and connections
- Transactions
- Caching
- Dialects
- Multi-tenancy
- Sharding
- gooq-gen (codegen tool)
- Testing
Query building
- Offline rendering:
ToSql(dialect)produces SQL and arguments without a database connection; execution is a separate layer. - Type-safe fields and conditions:
Field[T]operators,IN/BETWEEN/LIKE/IS NULL, expression comparisons, chainedAND/OR/NOT, andEXISTS. - SELECT with joins (including LATERAL), self-joins, derived tables, correlated subqueries, CTEs and recursive CTEs, and
UNION/INTERSECT/EXCEPT. - Grouping and ordering:
ROLLUP/CUBE/GROUPING SETS,HAVING,DISTINCT, ordering by expression, andLIMIT/OFFSET/Page. - Row locks:
FOR UPDATE, shared locks,SKIP LOCKED, andNOWAITwhere the dialect supports them. - Expressions: arithmetic,
CASE,CAST, boundRawfragments, and customExpressionnodes. - Functions (
fn): string, math, date, aggregate, window,FILTER,GROUP_CONCAT, and dialect-awareDATE_FORMAT.
DML
- INSERT: positional values, generated records, batch chunks, and
INSERT ... SELECT. - Upsert:
ON DUPLICATE KEY,ON CONFLICT,MERGE,DoNothing, andExcluded. - UPDATE: chained
Set, mapData, generated records, expression values, and multi-table joins. - DELETE: soft-delete rewrite,
Unscoped()hard delete, and batch delete by key. RETURNINGwhere supported; unsupported combinations fail before SQL is produced.
Execution and database
- Reads:
Rows,Structs,Row,Value,Column,Count,Exists, and counted page reads. - Connection groups, master/slave routing with weights,
DB(name),Master(),Slave(), andSchema(). - Transactions: context propagation, nested savepoints, seven propagation modes, and explicit
Tx(tx). - Debug SQL logging, dry run, prepared statements, and OpenTelemetry spans.
- Result mapping: ordered
Result, column-keyedRecord, typed cell helpers (db.Get/db.Value/db.Assign),ormtags, andsql.Scanner/time.Timecells.
Built-in policies
- Soft delete: predicates from
TableMeta, join-aware placement, and automatic delete rewrite. - Row-level multi-tenancy: automatic predicates, context or statement values, scoped
UnTenant(), and write / conflict-target checks. - Sharding: injected
ShardingRule, table-level or query-levelShardBy,Physical(), scope isolation, failure on a missing shard value, and dynamic shard creation withCreateTableLike. - Caching: single-query and counted-page caches, pluggable adapters and codec, and group-aware keys.
- Validation: construction-time errors, dialect capability checks, and unsupported features fail before execution.
Dialects and tooling
- Eight built-in dialects: MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, GaussDB, Dameng, and SQLite, plus custom
DialectSpecimplementations with optional capability interfaces. - Eight built-in drivers and custom
db.Driverregistration. gooq-gen: typed tables and models, custom templates, sharding groups, tenant columns,tables init/check/diff, and dynamic shard ignore patterns.
Requires Go 1.24 or newer. Add the module with:
go get github.com/lanceadd/gooq@latestgo install github.com/lanceadd/gooq/cmd/gooq-gen@latest
gooq-gen init # optional: scaffold hack/gooq.yaml + templates
gooq-gen -l "mysql:root:pass@tcp(127.0.0.1:3306)/db" -p internalGenerates typed gooq table objects (table/) and model/ for all tables in one shot, or for the groups listed in hack/gooq.tables.yaml when that file exists. Each logical table produces table/user.table.go (type and helpers), table/user.meta.go (TableMeta), model/user.model.go (UserModel), and model/user.record.go (UserRecord). A sharded table also gets table/user.sharding.go; table/gooq_sharding.go is written when any sharded group exists. gooq-gen can also read hack/gooq.yaml under the gooq.gen node instead of CLI options. See the gooq-gen section below for configuration, sharding groups, and tenant settings.
This example is hand-written. Generated output splits the type and metadata into table/user.table.go and table/user.meta.go; see gooq-gen for the generated layout.
type UserTable struct {
*gooq.TableBase
Id gooq.Field[int64]
Name gooq.Field[string]
Age gooq.Field[int]
Status gooq.Field[string]
CreatedAt gooq.Field[time.Time]
DeletedAt gooq.Field[time.Time] // flagged as soft-delete column
}
var UserMeta = &gooq.TableMeta{
TableName: "user",
Fields: []gooq.FieldMeta{
{ColumnName: "id", LocalType: gooq.LocalTypeInt64, Primary: true, AutoIncrement: true},
{ColumnName: "name", LocalType: gooq.LocalTypeString},
{ColumnName: "age", LocalType: gooq.LocalTypeInt},
{ColumnName: "status", LocalType: gooq.LocalTypeString},
{ColumnName: "created_at", LocalType: gooq.LocalTypeDatetime},
{ColumnName: "deleted_at", LocalType: gooq.LocalTypeDatetime, SoftDelete: true},
},
}
// Constructor pattern: fields bind to the instance via NewFieldAt,
// so As/Clone are one-line reconstructions.
func newUserTable(alias ...string) *UserTable {
t := &UserTable{TableBase: gooq.NewTableBase(UserMeta)}
if len(alias) > 0 {
t.TableBase = t.TableBase.As(alias[0])
}
t.Id = gooq.NewFieldAt[int64](t.TableBase, "id")
t.Name = gooq.NewFieldAt[string](t.TableBase, "name")
t.Age = gooq.NewFieldAt[int](t.TableBase, "age")
t.Status = gooq.NewFieldAt[string](t.TableBase, "status")
t.CreatedAt = gooq.NewFieldAt[time.Time](t.TableBase, "created_at")
t.DeletedAt = gooq.NewFieldAt[time.Time](t.TableBase, "deleted_at")
return t
}
var User = newUserTable() // package-level table object
func (t *UserTable) As(alias string) *UserTable { return newUserTable(alias) } // JOINs / self-joins
func (t *UserTable) Clone() *UserTable { return newUserTable() }import (
"github.com/lanceadd/gooq" // core: Field/Table/Expression/Dialect
"github.com/lanceadd/gooq/dsl" // builders: Select/Insert/Update/Delete
)
sql, args, err := dsl.Select(User.Id, User.Name).
From(User).
Where(User.Age.Gt(18)).
Order(User.Id.Desc()).
Limit(10).
ToSql(gooq.DialectMySQL)
// sql: SELECT `user`.`id`, `user`.`name` FROM `user` WHERE (`user`.`age` > ?) AND `user`.`deleted_at` IS NULL ORDER BY `user`.`id` DESC LIMIT 10
// args: []any{18}deleted_at IS NULL appears automatically — the soft-delete condition comes from TableMeta, no manual filtering. ToSql() without a dialect renders MySQL.
import (
"github.com/lanceadd/gooq/db"
_ "github.com/lanceadd/gooq/drivers/mysql" // pgsql, sqlite: drivers/pgsql, drivers/sqlite
)
err := db.SetConfig(db.DefaultGroupName, db.Config{
Link: "mysql:root:pass@tcp(127.0.0.1:3306)/app",
})
var users []model.UserModel
rows, err := dsl.Select(User.AllFields()).From(User).
Where(User.Age.Gt(18)).
Rows(ctx)
err = rows.Structs(&users)// All columns / set difference / alias / distinct
dsl.Select(User.AllFields()).From(User) // SELECT `user`.`id`, `user`.`name`, ...
dsl.Select(User.Id).From(User).FieldsEx(User.CreatedAt, User.DeletedAt) // difference: remaining columns
dsl.Select(User.Name.As("nickname")).From(User) // SELECT `user`.`name` AS `nickname`
dsl.Select(User.Id).From(User).Distinct() // SELECT DISTINCT ...
// Paging
dsl.Select(User.Id).From(User).Limit(10) // ... LIMIT 10
dsl.Select(User.Id).From(User).Offset(20).Limit(10) // ... LIMIT 10 OFFSET 20
dsl.Select(User.Id).From(User).Offset(20) // MySQL: LIMIT 18446744073709551615 OFFSET 20; PG: OFFSET 20; SQLite: LIMIT -1 OFFSET 20
dsl.Select(User.Id).From(User).Page(2, 10) // ... LIMIT 10 OFFSET 10(1-based pages)
// Ordering: direction is part of the expression (Field.Asc()/Desc(), gooq.OrderAscExpr/DescExpr)
dsl.Select(User.Id).From(User).Order(User.Age.Desc(), User.Id.Asc()).ToSql(gooq.DialectPgsql)
// SELECT "user"."id" FROM "user" WHERE "user"."deleted_at" IS NULL ORDER BY "user"."age" DESC, "user"."id" ASC
dsl.Select(User.Id).From(User).OrderDesc(fn.Count(User.Id)) // shorthand: wrap each with DESC
// Order by expression / raw fragment — Order renders items as-is (NULLS etc. via Raw)
dsl.Select(User.Id).From(User).OrderDesc(gooq.Raw("ABS(`user`.`age`)"))
// ... ORDER BY ABS(`user`.`age`) DESC
dsl.Select(User.Id).From(User).Order(gooq.Raw("`user`.`age` DESC NULLS LAST"))
// ... ORDER BY `user`.`age` DESC NULLS LAST// Field operators: value type T is enforced at compile time.
User.Age.Gt(18) // `user`.`age` > ?
User.Age.Between(18, 60) // `user`.`age` BETWEEN ? AND ?
User.Status.In("vip", "admin") // `user`.`status` IN (?, ?)
User.Name.Like("j%") // `user`.`name` LIKE ?
User.DeletedAt.IsNull() // `user`.`deleted_at` IS NULL
// Expression operands (column comparisons, subqueries, Raw) go through EqExpr.
OrderItem.UserId.EqExpr(User.Id) // `order_item`.`user_id` = `user`.`id`
User.Id.InExpr(subquery) // `user`.`id` IN (SELECT ...)
// A comparison is a Condition and can chain And / Or / Not. The chain is left-associative.
User.Age.Gt(11).Or(User.Age.Lt(-1))
// (`user`.`age` > ? OR `user`.`age` < ?)
User.Age.Gt(11).Or(User.Age.Lt(-1).And(User.Status.Eq("vip")))
// (`user`.`age` > ? OR (`user`.`age` < ? AND `user`.`status` = ?))
User.Status.Eq("banned").Not()
// (NOT `user`.`status` = ?)
// Multiple Where calls are combined with AND. Combine fields with chained Or / And.
dsl.Select(User.Id).From(User).
Where(User.Age.Gt(11).Or(User.Age.Lt(-1))).
Where(User.Status.Eq("vip")).
ToSql(gooq.DialectMySQL)
// ... WHERE (`user`.`age` > ? OR `user`.`age` < ?) AND `user`.`status` = ? AND `user`.`deleted_at` IS NULL
// args: []any{11, -1, "vip"}
dsl.Select(User.Id).From(User).
Where(User.Age.Lt(18).Or(User.Status.Eq("vip"))).
Where(User.Name.Like("j%")).
ToSql(gooq.DialectMySQL)
// ... WHERE (`user`.`age` < ? OR `user`.`status` = ?) AND `user`.`name` LIKE ? AND `user`.`deleted_at` IS NULL
// args: []any{18, "vip", "j%"}
// The left side is not a Field[T]: use gooq.Gt, gooq.Eq, and the rest. Wrap Raw with Cond.
gooq.Gt(User.Age.Add(1), 11).Or(gooq.Lt(User.Age, 0))
// ((`user`.`age` + ?) > ? OR `user`.`age` < ?)
gooq.Cond(gooq.Raw("a = 1")).Or(User.Age.Lt(-1))
// (a = 1 OR `user`.`age` < ?)
// Dynamic assembly: Clone a base builder for reuse. One builder cannot be used concurrently;
// execution writes the resolved executor back onto it.
base := dsl.Select(User.Id).From(User)
q1 := base.Clone().Where(User.Age.Gt(18))
q2 := base.Clone().Where(User.Status.Eq("vip")) // independent
// A string passed to Fields is spliced as Raw SQL and is not quoted as an identifier.In and NotIn render no condition when the value list length is 0. A nil slice, an empty slice, and no arguments are the same, and WHERE drops that empty condition. The library does not rewrite it to (1 = 0) or (1 = 1), and it does not return an error. When the statement must match no rows, the caller skips execution at length 0.
u := User.As("u")
ur := UserRole.As("ur")
r := Role.As("r")
dsl.Select(u.Id, r.Name).From(u).
InnerJoin(ur).On(ur.UserId.EqExpr(u.Id)).
InnerJoin(r).On(r.Id.EqExpr(ur.RoleId)).
Where(r.Name.Eq("admin")).
ToSql(gooq.DialectMySQL)
// SELECT `u`.`id`, `r`.`name` FROM `user` AS `u` INNER JOIN `user_role` AS `ur` ON (`ur`.`user_id` = `u`.`id`) INNER JOIN `role` AS `r` ON (`r`.`id` = `ur`.`role_id`) WHERE (`r`.`name` = ?) AND `u`.`deleted_at` IS NULL AND `r`.`deleted_at` IS NULL
// Self-join: two alias instances of the same table.
u1 := User.As("u1")
u2 := User.As("u2")
dsl.Select(u1.Id, u2.Id).From(u1).InnerJoin(u2).On(u1.Id.EqExpr(u2.Id))
// SELECT `u1`.`id`, `u2`.`id` FROM `user` AS `u1` INNER JOIN `user` AS `u2` ON (`u1`.`id` = `u2`.`id`) WHERE `u1`.`deleted_at` IS NULL AND `u2`.`deleted_at` IS NULL
// LATERAL derived tables. SQLite 3.46 does not support LATERAL and returns an error at render time.
lt := dsl.Select(fn.Count(UserRole.UserId).As("cnt")).
From(UserRole).Where(UserRole.UserId.EqExpr(u.Id)).As("lt")
dsl.Select(u.Id, lt.Field("cnt")).From(u).LeftJoinLateral(lt).On(gooq.Raw("1 = 1")).ToSql(gooq.DialectPgsql)
// ... LEFT JOIN LATERAL (SELECT COUNT("user_role"."user_id") AS "cnt" FROM "user_role" WHERE "user_role"."user_id" = "u"."id") AS "lt" ON 1 = 1 ...sub := dsl.Select(Role.Id).From(Role).Where(Role.Name.Eq("admin"))
dsl.Select(User.Id).From(User).Where(User.Id.InExpr(sub))
// ... WHERE (`user`.`id` IN (SELECT `role`.`id` FROM `role` WHERE (`role`.`name` = ?) AND `role`.`deleted_at` IS NULL)) ...
dsl.Select(User.Id).From(User).Where(gooq.Exists(sub))
// ... WHERE EXISTS (SELECT ...) ...
dsl.Select(User.Id).From(User).Where(gooq.NotExists(sub))
// Derived tables.
dsl.Select(User.Id).From(
dsl.Select(User.Id).From(User).Where(User.Age.Gt(18)).As("t"),
)
// SELECT `user`.`id` FROM (SELECT `user`.`id` FROM `user` WHERE (`user`.`age` > ?) AND `user`.`deleted_at` IS NULL) AS `t`
// Correlated subqueries: reference the outer alias inside.
u := User.As("u")
ur := UserRole.As("ur")
dsl.Select(u.Id).From(u).Where(gooq.Exists(
dsl.Select(ur.UserId).From(ur).Where(ur.UserId.EqExpr(u.Id)),
))
// ... WHERE (EXISTS (SELECT `ur`.`user_id` FROM `user_role` AS `ur` WHERE (`ur`.`user_id` = `u`.`id`))) ...dsl.Select(User.Status, fn.Count(User.Id)).
From(User).
Group(User.Status).
Having(gooq.Gt(fn.Count(User.Id), 2)).
ToSql(gooq.DialectMySQL)
// SELECT `user`.`status`, COUNT(`user`.`id`) FROM `user` WHERE `user`.`deleted_at` IS NULL GROUP BY `user`.`status` HAVING COUNT(`user`.`id`) > ?
// args: []any{2}
// Grouping extensions (dialect-aware: Rollup on MySQL/PG, Cube/GroupingSets PG-only — error on MySQL).
dsl.Select(User.Status).From(User).GroupRollup(User.Status).ToSql(gooq.DialectMySQL)
// ... GROUP BY `user`.`status` WITH ROLLUP
dsl.Select(User.Status).From(User).GroupCube(User.Status).ToSql(gooq.DialectPgsql)
// ... GROUP BY CUBE("user"."status")dsl.Select(User.Id).From(User).Where(User.Age.Eq(1)).
UnionAll(dsl.Select(User.Id).From(User).Where(User.Age.Eq(2)))
// ... UNION ALL ...
// ORDER BY after a set operation can only name a result column. A table-qualified column is rejected
// with 1250 on MySQL, TiDB, and OceanBase, and the PostgreSQL family rejects it too.
// dsl.Select(User.Id).From(User).UnionAll(...).Order(gooq.Raw("id"))
dsl.Select(User.Id).From(User).
Intersect(dsl.Select(User.Id).From(User)).
Except(dsl.Select(User.Id).From(User))
// ... INTERSECT ... EXCEPT ...
// CTE / recursive CTE.
dsl.With("adults", dsl.Select(User.Id).From(User).Where(User.Age.Gt(18))).
Fields(dsl.Cte("adults").Field("id")).From(dsl.Cte("adults")).ToSql(gooq.DialectPgsql)
// WITH adults AS (SELECT "user"."id" FROM "user" WHERE "user"."age" > $1 AND "user"."deleted_at" IS NULL) SELECT "adults"."id" FROM "adults"
dsl.WithRecursive("t", dsl.Select(User.Id).From(User).Where(User.Id.Eq(1)))
// WITH RECURSIVE t AS (...)// String / math / date / aggregate / general.
fn.Concat(User.Name, gooq.Str(","), User.Status) // CONCAT(`user`.`name`, ',', `user`.`status`)
fn.Coalesce(User.Name, gooq.Str("unknown")) // COALESCE(`user`.`name`, 'unknown')
fn.Substring(User.Name, 1, 3) // SUBSTRING(`user`.`name`, ?, ?)
fn.DateDiff(User.CreatedAt, fn.Now()) // DATEDIFF(`user`.`created_at`, NOW())
fn.CountDistinct(User.Status) // COUNT(DISTINCT `user`.`status`)
// Date formatting across dialects: one expression, per-dialect rendering.
dsl.Select(fn.DateFormat(User.CreatedAt, "%Y-%m-%d")).From(User).ToSql(gooq.DialectMySQL)
// SELECT DATE_FORMAT(`user`.`created_at`, '%Y-%m-%d') FROM ...
dsl.Select(fn.DateFormat(User.CreatedAt, "%Y-%m-%d")).From(User).ToSql(gooq.DialectPgsql)
// SELECT TO_CHAR("user"."created_at", 'YYYY-MM-DD') FROM ...
dsl.Select(fn.DateFormat(User.CreatedAt, "%Y-%m-%d")).From(User).ToSql(gooq.DialectSQLite)
// SELECT strftime('%Y-%m-%d', "user"."created_at") FROM ...
// String aggregation (MySQL GROUP_CONCAT / PG STRING_AGG / SQLite GROUP_CONCAT).
fn.GroupConcat(fn.GroupConcatOptions{
Field: User.Name, Separator: "-", OrderBy: []gooq.Expression{User.Name.Asc()},
}).ToSql(...) // GROUP_CONCAT(`user`.`name` ORDER BY `user`.`name` ASC SEPARATOR '-')
// Window functions.
fn.Rank().Over([]gooq.Expression{User.Status}, []gooq.Expression{User.Age.Desc()}).As("r")
// RANK() OVER (PARTITION BY `user`.`status` ORDER BY `user`.`age` DESC) AS `r`
fn.RowNumber().Over(nil, []gooq.Expression{User.Id.Asc()})
// ROW_NUMBER() OVER (ORDER BY `user`.`id` ASC)
fn.Sum(User.Age).OverFrame(
[]gooq.Expression{User.Status},
[]gooq.Expression{User.Id.Asc()},
fn.RowsFrame("UNBOUNDED PRECEDING", "CURRENT ROW"),
)
// SUM(`user`.`age`) OVER (PARTITION BY `user`.`status` ORDER BY `user`.`id` ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
// CASE.
gooq.Case().When(User.Age.Gt(60)).Then(gooq.Str("old")).
When(User.Age.Gt(18)).Then(gooq.Str("adult")).
Else(gooq.Str("young")).End().As("bucket")
// CASE WHEN `user`.`age` > ? THEN 'old' WHEN `user`.`age` > ? THEN 'adult' ELSE 'young' END AS `bucket`
// Arithmetic & casting.
User.Age.Mul(2) // (`user`.`age` * ?)
gooq.Add(User.Age, gooq.Mul(2, User.Age)) // (`user`.`age` + (? * `user`.`age`))
gooq.Negate(User.Age) // (-`user`.`age`)
User.Age.Cast(gooq.LocalTypeString) // CAST(`user`.`age` AS CHAR)(PG: TEXT / SQLite: TEXT)
// Raw: structured SQL with parameter binding. Placeholders are not rewritten.
// Use ? for MySQL and SQLite. PostgreSQL keeps the text as written, so write $n yourself.
gooq.Raw("JSON_EXTRACT(data, ?)", "$.name")
dsl.Select(User.Id).From(User).Where(gooq.Raw("age > ?", 18))
// Generic functions render NAME(args...) as-is (no dialect awareness).
dsl.Select(fn.New("JSON_EXTRACT", User.Name, gooq.Str("$.key"))).From(User)
// SELECT JSON_EXTRACT(`user`.`name`, '$.key') FROM ...
// Attach a dialect render strategy to any FuncExpr (instance-scoped, no global registry):
concatPipe := fn.New("CONCAT", User.Name, gooq.Str("-x")).
RenderWith(func(rc *gooq.RenderContext, argsSQL []string) string {
if rc.Dialect() == gooq.DialectPgsql {
return "(" + argsSQL[0] + " || " + argsSQL[1] + ")"
}
return "CONCAT(" + argsSQL[0] + ", " + argsSQL[1] + ")"
})
// A custom expression node implements gooq.Expression. Render returns SQL; arguments go through rc.AddArg.dsl.Select(User.Id).From(User).LockForUpdate().ToSql(gooq.DialectMySQL) // ... FOR UPDATE
dsl.Select(User.Id).From(User).LockInShareMode().ToSql(gooq.DialectMySQL) // ... LOCK IN SHARE MODE
dsl.Select(User.Id).From(User).LockInShareMode().ToSql(gooq.DialectPgsql) // ... FOR SHARE
dsl.Select(User.Id).From(User).LockForUpdate().ToSql(gooq.DialectSQLite) // ignored on SQLite
dsl.Select(User.Id).From(User).LockForUpdate().SkipLocked().ToSql(gooq.DialectMySQL)
// ... FOR UPDATE SKIP LOCKED
dsl.Select(User.Id).From(User).LockForUpdate().NoWait().ToSql(gooq.DialectPgsql)
// ... FOR UPDATE NOWAITSkipLocked() and NoWait() must follow LockForUpdate() or LockInShareMode(). The later wait policy replaces the earlier one. The MySQL family, PostgreSQL, and GaussDB emit the clause. SQLite and Dameng fail in ToSql instead of dropping the clause and running the query.
// Only setters that were called are written. SetAge(0) writes 0. SetDeletedAtNull writes NULL.
dsl.Insert(User).ValuesOf(model.NewUserRecord().SetName("john").SetAge(0))
// INSERT INTO `user` (`name`, `age`) VALUES (?, ?)
// Positional columns/values, repeated Values for batch.
dsl.Insert(User).Columns(User.Name, User.Age).Values("a", 1).Values("b", 2)
// INSERT INTO `user` (`name`, `age`) VALUES (?, ?), (?, ?)
// Several record rows. Batch chunks execution and aggregates RowsAffected. Each chunk commits on its own; wrap the call in db.Transaction when the chunks must be atomic.
dsl.Insert(User).ValuesOf(
model.NewUserRecord().SetName("a"),
model.NewUserRecord().SetName("b"),
).Batch(100).Exec(ctx)
// Rows that set different columns become separate INSERT statements at Exec.
// Omitted columns keep the table default. ToSql rejects that shape; call Exec.
// Batch splits inside each column set and does not pad missing columns with NULL.
// INSERT ... SELECT.
dsl.InsertFrom(User, dsl.Select(User.Name).From(User).Where(User.Age.Gt(18)))
// INSERT INTO `user` (`name`) SELECT `user`.`name` FROM `user` WHERE (`user`.`age` > ?) AND `user`.`deleted_at` IS NULL// Chained Set.
dsl.Update(User).Set(User.Name, "x").Set(User.Age, 20).Where(User.Id.Eq(1))
// UPDATE `user` SET `name` = ?, `age` = ? WHERE (`user`.`id` = ?)
// Map full update.
dsl.Update(User).Data(map[string]any{"age": 1, "name": "x"})
// UPDATE `user` SET `age` = ?, `name` = ?
// Expression values render as SQL fragments (field arithmetic, Raw, etc.).
dsl.Update(User).Set(User.Age, User.Age.Add(1)).Where(User.Id.Eq(1))
// UPDATE `user` SET `age` = (`user`.`age` + ?) WHERE (`user`.`id` = ?)
// One record row becomes SET. Several rows update by primary key. RowsAffected is aggregated.
dsl.Update(User).ValuesOf(model.NewUserRecord().SetName("x").SetAge(0)).Where(User.Id.Eq(1))
dsl.Update(User).ValuesOf(
model.NewUserRecord().SetId(1).SetName("a"),
model.NewUserRecord().SetId(2).SetName("b"),
).Exec(ctx)
// UPDATE `user` SET `name` = ? WHERE `id` = ? ×2
// Multi-table update (MySQL JOIN / PostgreSQL, SQLite, and Dameng FROM).
u := User.As("u"); r := Role.As("r")
dsl.Update(u).Set(u.Status, "vip").InnerJoin(r).On(r.Id.EqExpr(u.Id)).ToSql(gooq.DialectMySQL)
// UPDATE `user` AS `u` INNER JOIN `role` AS `r` ON `r`.`id` = `u`.`id` SET `status` = ?
dsl.Update(u).Set(u.Status, "vip").InnerJoin(r).On(r.Id.EqExpr(u.Id)).ToSql(gooq.DialectPgsql)
// UPDATE "user" AS "u" SET "status" = $1 FROM "role" AS "r" WHERE "r"."id" = "u"."id"
// Any LEFT / RIGHT / FULL join makes PostgreSQL, GaussDB, SQLite, and Dameng return an error at render time.
// MySQL keeps the outer join and writes it before SET.UPDATE does not add the soft-delete predicate, including for joined tables. Add Where(User.DeletedAt.IsNull()) explicitly when already-deleted rows must be skipped.
// Soft-delete tables: DELETE auto-rewrites to UPDATE deleted_at.
dsl.Delete(User).Where(User.Id.Eq(1))
// UPDATE `user` SET `deleted_at` = ? WHERE (`user`.`id` = ?)
// Unscoped(): real DELETE.
dsl.Delete(User).Unscoped().Where(User.Id.Eq(1))
// DELETE FROM `user` WHERE (`user`.`id` = ?)
// Non-soft-delete tables delete directly.
dsl.Delete(UserRole).Where(UserRole.Id.Eq(1))
// DELETE FROM `user_role` WHERE (`user_role`.`id` = ?)
// Batch delete by primary key. ValuesOf is not used on Delete.
dsl.Delete(User).Where(User.Id.In(1, 2)).Exec(ctx)// MySQL: DoUpdate writes the given value. OnConflictKey is optional.
dsl.Insert(User).Columns(User.Name, User.Age).Values("a", 1).
OnConflictKey(User.Id).DoUpdate(User.Name, "x").ToSql(gooq.DialectMySQL)
// INSERT INTO `user` (`name`, `age`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = ?
// Write the value from this insert: VALUES(col) on MySQL, EXCLUDED.col on PostgreSQL / SQLite.
dsl.Insert(User).Columns(User.Name, User.Age).Values("a", 1).
DoUpdate(User.Name, gooq.Excluded(User.Name)).ToSql(gooq.DialectMySQL)
// INSERT INTO `user` (`name`, `age`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)
// MySQL: INSERT IGNORE.
dsl.Insert(User).Columns(User.Name).Values("a").DoNothing().ToSql(gooq.DialectMySQL)
// INSERT IGNORE INTO `user` (`name`) VALUES (?)
// PG: ON CONFLICT.
dsl.Insert(User).Columns(User.Name, User.Age).Values("a", 1).
OnConflictKey(User.Id).DoUpdate(User.Name, "x").ToSql(gooq.DialectPgsql)
// INSERT INTO "user" ("name", "age") VALUES ($1, $2) ON CONFLICT ("id") DO UPDATE SET "name" = $3
dsl.Insert(User).Columns(User.Name).Values("a").
OnConflictKey(User.Id).DoNothing().ToSql(gooq.DialectPgsql)
// INSERT INTO "user" ("name") VALUES ($1) ON CONFLICT ("id") DO NOTHING
// Returning: PostgreSQL, GaussDB, and SQLite; MySQL and Dameng return an error.
dsl.Update(User).Set(User.Status, "vip").Where(User.Id.Eq(1)).
Returning(User.Id).ToSql(gooq.DialectPgsql)
// UPDATE "user" SET "status" = $1 WHERE "user"."id" = $2 RETURNING "user"."id"// Query into a struct slice. Omitting DB uses the default group.
var users []model.UserModel
rows, err := dsl.Select(User.AllFields()).From(User).
Where(User.Age.Gt(18)).Order(User.Id.Desc()).Limit(10).
Rows(ctx)
err = rows.Structs(&users)
var count int64
err = dsl.Select(fn.Count(User.Id)).From(User).Value(ctx, &count)
// One column. time.Time and sql.Null* are cells, so they use Value or Column.
// A row struct uses Row and db.Record.Struct.
var created time.Time
err = dsl.Select(User.CreatedAt).From(User).Where(User.Id.Eq(1)).Value(ctx, &created)
var names []string
err = dsl.Select(User.Name).From(User).Column(ctx, &names)
// DML.
_, err = dsl.Insert(User).ValuesOf(model.NewUserRecord().SetName("john")).Exec(ctx)
_, err = dsl.Update(User).Set(User.Status, "vip").Where(User.Id.Eq(1)).Exec(ctx)
// Count is the number of rows after pagination and ordering are removed.
// DISTINCT, grouping, and set operations are counted through a subquery.
// Exists reports whether that count is greater than zero: the select list becomes 1, and pagination and ordering are removed.
// Dameng uses SELECT COUNT(*) FROM (SELECT 1 ...) WHERE ROWNUM = 1. Other dialects use SELECT EXISTS (SELECT 1 ...).
// Value and Column require exactly one column, including RETURNING. Row and Value add LIMIT 1 when Limit is absent, and return an error when Limit is greater than 1.
total, err := dsl.SelectFrom(User).Where(User.Status.Eq("vip")).Count(ctx)
exists, err := dsl.Select(User.Id).From(User).Where(User.Account.Eq("x")).Exists(ctx)
// Typed row reads: the field type T is consumed at compile time.
row, err := dsl.Select(User.Id, User.Name, User.Age).From(User).
Where(User.Name.Eq("john")).Row(ctx)
id := db.Get(row, User.Id) // int64
name := db.Get(row, User.Name) // stringExecution binds DB(name) or the default configuration group and joins a transaction from ctx. See Configuration and connections and Transactions.
DB("orders") selects that configuration group. Omitting DB uses the default group. LoadConfig reads gooq.database, gooq.defaultGroup, and gooq.logger through gcfg. Runtime reads only a file named gooq, such as gooq.yaml, and does not read config.yaml.
database is a list of groups: name is the group, endpoints are its master and slave nodes. A single group may omit name and is stored as default. Every group needs a name when there is more than one. defaultGroup selects the group used when the name is omitted; it must be one of the groups in this file; omitted, it is default.
Reads use a slave when the group has one; writes and BEGIN use a master. Master() and Slave() force the pool. SetDebug logs SQL, SetDryRun skips execution, Prepare prepares a statement, and Schema switches the MySQL database or PostgreSQL search_path.
gooq:
defaultGroup: orders
database:
- name: default
endpoints:
- link: "pgsql:postgres:postgres@tcp(127.0.0.1:5432)/test"
role: master
debug: true
- name: orders
endpoints:
- link: "pgsql:postgres:postgres@tcp(127.0.0.1:5432)/orders"
role: master
- link: "pgsql:postgres:postgres@tcp(127.0.0.1:5433)/orders"
role: slaveTransaction puts the tx into ctx. Queries inside the callback join it:
database, err := db.Instance()
err = database.Transaction(ctx, func(ctx context.Context, tx *db.Tx) error {
_, err = dsl.Update(User).Set(User.Status, "vip").Where(User.Id.Eq(1)).Exec(ctx)
return err
})A transaction is taken from ctx and joins only the pool that started it. Another registered group, another Open, and a Schema that opened its own pool do not use that transaction and do not fail because it is present. Master, Slave, WithRoute, and a Schema that keeps the shared pool still join.
Concurrent requests may share one DB; each transaction lives in its own ctx. Do not hand a transactional ctx or Tx to another request, and do not run one Tx concurrently. dsl Tx(tx) runs that statement on the given connection; otherwise it still joins from ctx.
// Inject a global cache adapter (e.g. a gredis-backed implementation).
dsl.SetCacheAdapter(redisAdapter)
dsl.SetHashCacheAdapter(redisHashAdapter)
// Cached values are JSON unless SetCacheCodec installs another process-wide codec.
// For Row and Rows, the number and time types after a hit come from that codec.
// JSON keeps integers as json.Number and times as RFC3339 strings.
dsl.SetCacheCodec(codec)
// Single-query cache: Value, Column, Row, Rows, Count, and Exists go through it.
// Cache() with no arg or a zero option disables caching; a custom Name fixes the key
// (useful for manual invalidation). Argument keys include the type name, so
// int64(1) and "1" do not share a key.
// A generated key includes the config group that runs the query. Omitting DB() uses the
// current default group. A custom Name does not gain a group name.
rows, err := dsl.Select(User.AllFields()).From(User).
Cache(dsl.CacheOption{Duration: time.Minute}).
Rows(ctx)
// Composite query: count runs first, rows after; count=0 short-circuits without querying rows.
rows, total, err := dsl.SelectFrom(User).Where(User.Status.Eq("vip")).
Order(User.Id.Desc()).
Page(1, 10).RowsAndCount(ctx)
// PageCache caches a counted page as one hash record (fields "count" and "rows").
// It applies to RowsAndCount, StructsAndCount, and ColumnAndCount.
// Requires SetHashCacheAdapter. The key hashes the count SQL and the page SQL together,
// arguments included, so selected columns, filters, order, and page each change the key.
// RowsField/CountField override the field names; Force caches empty
// results (count=0) which are skipped by default.
rows, total, err = dsl.SelectFrom(User).Where(User.Status.Eq("vip")).
Order(User.Id.Desc()).
PageCache(dsl.CacheOption{Duration: time.Minute}).
Page(1, 10).RowsAndCount(ctx)
// StructsAndCount fills a struct slice and returns the total.
// It is RowsAndCount followed by Result.Structs, and shares that page cache.
var vips []User
total, err = dsl.SelectFrom(User).Where(User.Status.Eq("vip")).
Order(User.Id.Desc()).
PageCache(dsl.CacheOption{Duration: time.Minute}).
Page(1, 10).StructsAndCount(ctx, &vips)| Dialect | Placeholder | Quote | Pagination |
|---|---|---|---|
| mysql, mariadb, tidb, oceanbase | ? |
` |
LIMIT |
| pgsql, gaussdb | $n |
" |
LIMIT |
| sqlite | ? |
" |
LIMIT |
| dm | ? |
" (uppercase) |
LIMIT |
ToSqlrequires a registeredDialectSpec. An unknown dialect returns an error. EmbedMySQLDialect,PostgresDialect, orSQLiteDialectand override the methods that differ, then callRegisterDialect.DialectSpeccovers statement shape: pagination, row locks,JOINrewrites, upsert, delete, multi-tableUPDATE(RenderUpdate), and existence queries (RenderExists). Function SQL such asDATE_FORMAT,GROUP_CONCAT,IFNULL,CURDATE,DATE_ADD,DATEDIFF,ROUND, andMEDIANstays on the concrete dialect type. A dialect that does not implement one returnsdialect %q does not support ...fromToSql. SQLite upsert usesON CONFLICT.- Custom dialects with positional placeholders must keep argument order aligned with SQL text:
- Implement
UpdateJoinsBeforeSet() boolwhenUPDATEwritesJOINbeforeSET(the MySQL dialect does). - Implement
FoldIdent() boolwhen the database treats identifiers case-insensitively; tenant write checks use it. Numbered placeholders ($n) need neither. - Implement
UpsertConflictTarget() booland return true when upsert uses an explicit conflict target (ON CONFLICTorMERGE). A dialect that does not implement it skips the tenant conflict-target check. - Implement
ReleaseSavepoint() booland return false when the database has noRELEASE SAVEPOINT; a nested commit then keeps the savepoint and the outer transaction releases it.
- Implement
- Table, column, and function aliases go through
Quote.numeric/decimalgeneratestring. PostgreSQL arrays generate a slice of the element type.
- Opening a connection requires a blank import of the matching driver package (
drivers/mysql,drivers/mariadb,drivers/tidb,drivers/oceanbase,drivers/pgsql,drivers/gaussdb,drivers/dm,drivers/sqlite). The driver registers itself withdb.Register.gooq-genimports these drivers.
- MariaDB, TiDB, and OceanBase use the MySQL wire protocol and the MySQL statement shape. MariaDB reads columns from
information_schema, so a JSON value stored aslongtextwith ajson_validcheck is reported asjson. On TiDB,LockInShareModereturns an error by default. Aftertidb_enable_noop_functionsis enabled the statement runs, but it does not take a shared lock.LockForUpdateworks. - GaussDB means openGauss. Quoting, placeholders, row locks,
RETURNING, deletes, and updates follow PostgreSQL.DATE_ADDis interval multiplication, notmake_interval. Upsert isON DUPLICATE KEY UPDATE, and the inserted value isVALUES(col).DoNothingreturns an error because GaussDB has noON CONFLICT DO NOTHING. Thedatabase/sqldriver name isopengauss. - Dameng (
dm):- Quotes identifiers in uppercase so they match names stored from unquoted DDL.
- Upsert is
MERGEand needsOnConflictKey; the conflict column must be in the insert list.DoNothinginserts only when the row is not matched. - There is no
RETURNINGand noRELEASE SAVEPOINT; a nested transaction commit keeps the savepoint, and the outer transaction or a later savepoint with the same name releases it. ExistsrendersSELECT COUNT(*) FROM (subquery) WHERE ROWNUM = 1.GROUP_CONCATrenders asLISTAGG,DATE_FORMATasTO_CHAR,IFNULLasNVL, andDATE_ADDasDATEADD.time.Timeis bound as a timestamp string; the zero time becomesNULL. An IANA timezone in the config becomes a minute offset, and an offset outside ±12 hours fails.DELETEwithJOINandUPDATEwith an outer join return an error. Identity columns are not markedAutoIncrement.- The
database/sqldriver name isdm. Link example:dm:SYSDBA:SYSDBA001@tcp(127.0.0.1:5236)/SYSDBA.
- Schema-qualified tables render
schema.table.column(alias shadows schema); gooq-gen fills schema for PG only (current_schema()). - PostgreSQL 16 can run
STRING_AGG(DISTINCT ...),JSON_ARRAYAGG, andANY_VALUE.
A tenant column is part of TableMeta, like a primary key or a soft-delete column. gooq-gen marks it only when hack/gooq.yaml sets gooq.gen.tenant.column. A missing or null tenant node leaves generation unchanged. Otherwise the node must be a map with a non-empty column; unknown fields are rejected. tables overrides that column for one logical table. An empty string turns the mark off for that table.
gooq:
gen:
tenant:
column: tenant_id
tables:
shop_order: org_id
dict: ""Generation-time column matching ignores case and underscores. Runtime tenant-column checks follow the dialect: MySQL family, SQLite, and DM compare case-insensitively; PostgreSQL and GaussDB compare exactly. Allowed values are string, uuid.UUID, and the integer widths. int and int64 are not the same type.
The value comes from dsl.WithTenant(ctx, id) or from Tenant(id) on that builder. WithTenant carries one value per request; Tenant(ids...) is per-statement and renders tenant_col IN (...). Writes (INSERT, or setting the tenant column in UPDATE) require exactly one value, and a slice passed to WithTenant is a type error, not multi-tenancy. ToSql does not read the context. Execution records the context value on the builder, then renders it into a new RenderContext. A statement that touches a tenant table and has no value returns an error. It does not emit IS NULL.
Tenant(id) applies to that builder and to queries rendered inside it. A nested builder may call Tenant(other) or UnTenant(). The later call wins: UnTenant().Tenant(7) adds the predicate, and Tenant(7).UnTenant() does not. Tenant() with no arguments only turns filtering back on. An earlier Tenant(id) stays in place; otherwise the value comes from the request context. UnTenant() turns tenant filtering off for every query that builder renders: the main table, joins, subqueries, CTEs, and UNION branches. A nested builder turns it back on for its own subtree by calling Tenant(id).
LEFT JOIN, LEFT JOIN LATERAL, INNER JOIN, INNER JOIN LATERAL, and CROSS JOIN LATERAL keep their current shape. A RIGHT JOIN or FULL JOIN in a statement that touches any tenant table returns an error. Rewrite that join as LEFT JOIN, or call UnTenant().
INSERTinto a tenant table must set the tenant column to the single current tenant value. The column is not filled in automatically.INSERT ... SELECTmust list named columns and include the tenant column.Select().From(src)and aRawselect item fail before SQL is produced.UnTenant()skips these checks.- Do not set the tenant column in
UPDATEor in an upsert assignment. If it is set, the value must equal the current tenant; another value,NULL, or an expression returns an error. - For
INSERT ... SELECT, only the column list is checked; selected values are not compared statically. OnConflictKeyis not rewritten. On PostgreSQL, SQLite, and Dameng, a tenant table'sOnConflictKeymust include the tenant column for bothDoUpdateandDoNothing. MySQL, MariaDB, TiDB, OceanBase, and GaussDB pick the unique key in the database, so every unique key on a tenant table must include the tenant column.Unscoped()only bypasses soft delete;UnTenant()only bypasses tenant filtering.
CacheOption.Name is the cache key as written. Put the tenant in that name. Without Name, the key hashes the SQL and arguments, so different tenants do not share an entry.
Each user WHERE and ON predicate is parenthesized before a soft-delete or tenant predicate is appended with AND.
A sharded table keeps its tenant mark through Physical(); call UnTenant() for cross-tenant scans or migrations.
Shard groups live in hack/gooq.tables.yaml. single lists ordinary tables. groups lists sharded tables. Without that file, each physical table is generated as its own object. gooq-gen tables init writes the file with every table under single. tables check reports duplicate, missing, and extra names. tables diff reports tables added or removed in the database. Neither command rewrites the file. Tables matching gooq.tables.check.ignore are excluded from the missing report and from diff new tables; listed tables are still required to exist.
# hack/gooq.tables.yaml
single:
- "dict"
- "user"
groups:
- name: "order"
table:
- "order_00"
- "order_01"The first table in a group supplies the schema used for generation; every table in the group must match it. name is the logical table name, so the example generates table/order.table.go, table/order.meta.go, table/order.sharding.go, and table/gooq_sharding.go. gooq-gen tables init only fills single and leaves groups: [] for you to edit.
A group generates one logical table, RegisterXxxSharding, and a package-level RegisterSharding. One call to RegisterSharding assigns that rule to every sharded logical table. Use RegisterXxxSharding to replace the rule on a table that shards differently. A nil rule panics. Registering again overwrites. The rule is stored on TableMeta.Sharding.
// generated table/user.sharding.go — sharded tables only
func RegisterUserSharding(rule gooq.ShardingRule) {
if rule == nil {
panic("gooq: sharding rule is required")
}
UserMeta.Sharding = rule
}
// application package (blank-imported so its init runs first)
type userRule struct {
shards int64
}
func (r userRule) TableName(table gooq.ShardingTableMeta, value any) (string, error) {
uid, ok := value.(int64)
if !ok {
return "", fmt.Errorf("user shard key must be int64, got %T", value)
}
n := uid % r.shards
if n < 0 {
n += r.shards
}
return fmt.Sprintf("%s_%02d", table.LogicalName, n), nil
}
func init() { table.RegisterSharding(userRule{shards: 16}) }
// ShardBy applies only to the main table (From / Insert / Update / Delete).
err := dsl.Select(table.User.Id, table.User.Name).From(table.User).
ShardBy(uid).Value(ctx, &name)
// a joined sharded table carries its own value; a plain table does not
r := table.UserRole.ShardBy(uid)
err = dsl.Select(table.User.Name, r.Name).From(table.User).
InnerJoin(r).On(r.UserId.EqExpr(table.User.Id)).
ShardBy(uid).Value(ctx, &name)- The first argument of
TableNameisShardingTableMeta.LogicalNameis the logical table name.ShardNameslists the physical shard tables that existed when the code was generated; it is informational, and the returned name is not checked against it. The return value is one unquoted table name, such asuser_03. gooq quotes it. Leave schema onTableMeta; do not put it in the return value. A non-negative id can useuid % shardsdirectly. Do not take the absolute value first:-3and3would land on the same shard. Do not uselen(ShardNames)as the divisor. The list is only the tables that existed then. ShardByon a query applies only to that query's main table. A subquery, CTE, or each branch ofUNIONsets its own value and does not inherit the outer one. Another sharded table in the query callsShardByand returns a new table;AsandClonekeep that value. A sharded table with neitherShardBynorPhysicalfails inToSql.role.ShardBy(uid)does not change the originalrole. Use fields from the returned object inONand the select list.role.UserIdis still bound to the original table and does not carry this value.- A correlated subquery that references an outer sharded table's column needs that table to carry a table-level
ShardBy(u := table.User.ShardBy(uid)); use fields from the returned object inside the subquery too. A query-levelShardByon the outer query alone fails at render time. - Two sharded tables may carry different values. gooq does not check that they use the same rule. SQL still runs when the values do not belong together.
Physical("user_05")clears the rule and uses that table name. An empty name panics.- Generation compares column name, order,
LocalType, nullability, primary key, auto-increment, and uniqueness against the first table in the group. A group with one table skips that check. String length, defaults, and comments are not compared.
- Sharded queries can use
CacheandPageCache. WithoutName, the key is computed after SQL is rendered and includes the physical table name, arguments, result shape, and database group. Different shards do not collide. This fits data that is read often and refreshed by expiry. WithName, the key is that string alone. SQL and arguments are not included. The function around the query must put its inputs in the name, for exampleuser-name:19. The name does not need to beuser_03. A fixedName: "user-name"makes different inputs share one entry. Writes do not delete cache entries. With an explicit name, the application deletes that same name.
- These conventions are for the caller. gooq does not reject violations. Do not keep a logical placeholder table in the database. Put the shard key in
WHERE. Do not use an AutoIncrement primary key; use a business key, or snowflake ids with embedded shard bits. While sharding, do not use cross-shard transactions, cross-database joins, or automatic merge or broadcast. Those statements still run. Cross-shard reads are explicit application loops, onePhysicalper shard. Group multi-row writes by shard first. OneShardBywrites one physical table. Rows from other shards in the same write go into that table and do not raise an error.Batchonly splits rows of that same table.
db.DB.CreateTableLike creates a new physical shard by cloning an existing one:
name, err := EmployeeMeta.Sharding.TableName(gooq.ShardingTableMeta{
LogicalName: EmployeeMeta.TableName,
ShardNames: EmployeeMeta.ShardNames,
}, companyID)
if err != nil {
return err
}
err = database.CreateTableLike(ctx, "employee_1", name)- MySQL family:
CREATE TABLE IF NOT EXISTSemployee_3LIKEemployee_1``. - PostgreSQL and GaussDB:
CREATE TABLE IF NOT EXISTS "employee_3" (LIKE "employee_1" INCLUDING ALL). - Indexes, defaults, and constraints are copied. Rows are not.
IF NOT EXISTSmakes repeat calls safe. An existing target table is left untouched, and its structure is not compared with the template.- SQLite and Dameng return a
does not support CREATE TABLE LIKEerror before any SQL is sent.
Pass unquoted names for both arguments; the dialect quotes them. Compute the target with the same ShardingRule the queries use. The method uses the db.DB instance's dialect and executes through Exec, so an open transaction in ctx is used.
Both tables must be in the same schema. The method does not read TableMeta.Schema; for non-default schemas, call it on a Schema(name) view. Grant DDL permissions to a dedicated connection or config group, not the everyday read/write pool. MySQL-family DDL commits implicitly, so it cannot be atomic with application writes. New shards clone the template's current schema; existing shards must be migrated separately. Queries never create tables; call this explicitly before the first read or write of a new shard. Concurrent creation on PostgreSQL can still return a catalog uniqueness error; retry it.
go install github.com/lanceadd/gooq/cmd/gooq-gen@latest # install the released binary
# or run from a checkout:
cd cmd/gooq-gen && go run . init # scaffold hack/gooq.yaml + hack/template/{table,meta,sharding,register,model,record}.tmpl
cd cmd/gooq-gen && go run . -l "mysql:root:pass@tcp(127.0.0.1:3306)/db" -p internal
cd cmd/gooq-gen && go run . -l "sqlite::@file(./test.db)" # SQLite examplegooq-gen searches for a hack directory from the current directory upward and uses hack/gooq.yaml when found. CLI flags take priority over the file. For the full command and flag reference, template details, and generated-file examples, see cmd/gooq-gen/README.MD.
gooq:
gen:
link: "mysql:root:pass@tcp(127.0.0.1:3306)/db"
path: internal
tenant:
column: tenant_id
tables:
shop_order: org_id
dict: ""
tables:
check:
ignore:
- "employee_*"- Run
gooq-gen initonce to scaffoldhack/gooq.yamland the templates. - Fill
gooq.gen.link(andpathif needed) inhack/gooq.yaml, or pass-l/-pon each run; CLI flags take priority. - If the schema has sharded tables, run
gooq-gen tables initonce, then editgroupsinhack/gooq.tables.yaml. See Sharding for the file format, checks, and ignore rules. - After a schema change, run
gooq-gen tables diffto see added/removed tables, updatehack/gooq.tables.yaml, then rungooq-gen tables check. - Run
gooq-gen(orgooq-gen -l ... -p ...) to regeneratetable/andmodel/. - Customize templates under
hack/template/instead of editing generated files; templates are never overwritten.
Regeneration overwrites the generated files of current tables. Files for tables removed from the database or hack/gooq.tables.yaml are not deleted; remove them yourself. table/gooq_sharding.go is removed automatically when no sharded groups remain. Write the tenant config before generating when it is used.
A non-sharded table generates:
table/<logical>.table.go type, fields, constructor, As/Clone
table/<logical>.meta.go TableMeta
model/<logical>.model.go scan model
model/<logical>.record.go write record
A sharded table also gets table/<logical>.sharding.go (RegisterXxxSharding, Physical, ShardBy). table/gooq_sharding.go exists when at least one sharded group is generated.
initcreateshack/gooq.yamlandhack/template/table.tmpl,meta.tmpl,sharding.tmpl,register.tmpl,model.tmpl,record.tmplfor customization; existing files are never overwritten.-d/--debugturns on debug logging.-p internal(the default) writesinternal/table/andinternal/model/. The package name is the directory base (table,model).table/gooq_sharding.gois generated only whenhack/gooq.tables.yamldefines sharded groups.- Config file: reads
hack/gooq.yamlundergooq.genforlinkandpath; CLI options take priority.gooq.gen.tenantis read by the CLI and passed into generation. See Multi-tenancy above.gooq.tables.check.ignorelists glob patterns; see Sharding for which tables it skips. - Templates:
<hack>/template/table.tmpl,meta.tmpl,sharding.tmpl,register.tmpl,model.tmpl, andrecord.tmpltake precedence over the embedded templates (delete one to fall back).sharding.tmplrenders the per-table sharding file;register.tmplrenders the package-levelgooq_sharding.go. -l/--linkdatabase link;-p/--pathoutput directory (defaultinternal).- Shard groups:
hack/gooq.tables.yaml, maintained withgooq-gen tables init,tables check, andtables diff. See Sharding for the format and ignore rules. model/<logical>.model.godefinesUserModel, a plain struct tagged withjsonandormfordb.Result.Structsand JSON.model/<logical>.record.godefinesUserRecord, whose fields aregooq.Property[T].model.NewUserRecord().SetName("john")marks columns forValuesOf. Nullable columns also getSetXxxNull.ToModel()copies set values intoUserModel.NewUserRecordFrommarks every column from aUserModel.- Metadata derivation: primary key (
PRI), auto-increment, soft delete (columns folding todeletedatordeleteat, such asdeleted_at/delete_at), unique (UNI),LocalTypemarkers; field naming (id→Id, matchinggf gen daooutput); a column whose name collides with a generated field or method fails generation and reports the column and the conflicting declaration. - Built-in connection drivers live in
drivers/mysql,drivers/mariadb,drivers/tidb,drivers/oceanbase,drivers/pgsql,drivers/gaussdb,drivers/dm, anddrivers/sqlite.gooq-genimports them. Library code blank-imports the one it uses. Link prefixes aremysql,mariadb,tidb,oceanbase,pgsql/postgres,gaussdb,dm, andsqlite.
go test ./... -count=1 # core packages: rendering, dsl, db, fn, drivers
cd cmd/gooq-gen && go test ./... # gooq-gen end-to-end (sqlite)
# Live driver suites are behind build tags and read the link from an env var.
GOOQ_MYSQL_LINK="mysql:root:pass@tcp(127.0.0.1:3306)/test" go test -tags mysql ./drivers/mysql
GOOQ_PG_LINK="pgsql:postgres:postgres@tcp(127.0.0.1:5432)/test" go test -tags pgsql ./drivers/pgsqlTags and env vars: mysql/GOOQ_MYSQL_LINK, mariadb/GOOQ_MARIADB_LINK, tidb/GOOQ_TIDB_LINK, oceanbase/GOOQ_OB_LINK, pgsql/GOOQ_PG_LINK, gaussdb/GOOQ_GAUSSDB_LINK, dm/GOOQ_DM_LINK.
MIT
gooq's design grew out of the shortcomings found while developing GoFrame's gdb, combined with ideas from jOOQ. The code evolved from the TableFields and SQL builder in GoFrame's gdb and is now developed independently.