From 2b986abae4fa298eafc990bb7c8f3abf043c9d3f Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 3 Jul 2026 17:48:29 +0300 Subject: [PATCH 1/2] =?UTF-8?q?refactor!:=20pre-v1.0=20API=20design=20?= =?UTF-8?q?=E2=80=94=20var=20to=20func,=20Tx=20symmetry,=20ToSQL,=20WithCo?= =?UTF-8?q?ntext,=20Params,=20Distinct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES (allowed on v0.x before API freeze): - 32 exported var → func: Eq, NotEq, And, Or, In, Like, IsUniqueViolation, etc. now proper functions with correct godoc signatures. ErrNotFound stays var (sentinel error value). - Distinct(bool) → Distinct() — no parameter, always enables DISTINCT. Tests using Distinct(false) restructured to omit the call. - Params() is now the primary method, QueryParams() deprecated. NEW FEATURES: - Tx: BatchInsert, BatchUpdate, Upsert, NewQuery — full parity with DB. - ToSQL() on UpsertQuery, BatchInsertQuery, BatchUpdateQuery — all 6 query types now support SQL preview. - ModelQuery.WithContext(ctx) — per-operation context on Model API. 7 files changed, 243 insertions, 125 deletions. --- db.go | 219 +++++++++++++++++++++------- db_coverage_test.go | 5 +- internal/core/builder.go | 13 +- internal/core/db.go | 10 ++ internal/core/distinct_demo_test.go | 26 +--- internal/core/distinct_test.go | 51 +++---- internal/core/model_query.go | 44 ++++-- 7 files changed, 243 insertions(+), 125 deletions(-) diff --git a/db.go b/db.go index 07d13e9..6bcdd41 100644 --- a/db.go +++ b/db.go @@ -51,8 +51,10 @@ import ( "context" "database/sql" "errors" + "log/slog" "reflect" "sort" + "time" "github.com/coregx/relica/internal/core" "github.com/coregx/relica/internal/logger" @@ -1152,6 +1154,60 @@ func (t *Tx) Model(model interface{}) *ModelQuery { return &ModelQuery{mq: t.tx.Model(model)} } +// BatchInsert creates a new batch INSERT query within the transaction. +// +// This is a convenience method equivalent to tx.Builder().BatchInsert(table, columns). +// +// Example: +// +// result, err := tx.BatchInsert("users", []string{"name", "email"}). +// Values("Alice", "alice@example.com"). +// Values("Bob", "bob@example.com"). +// Execute() +func (t *Tx) BatchInsert(table string, columns []string) *BatchInsertQuery { + return t.Builder().BatchInsert(table, columns) +} + +// BatchUpdate creates a new batch UPDATE query within the transaction. +// +// This is a convenience method equivalent to tx.Builder().BatchUpdate(table, keyColumn). +// +// Example: +// +// result, err := tx.BatchUpdate("users", "id"). +// Set(1, map[string]interface{}{"name": "Alice"}). +// Set(2, map[string]interface{}{"name": "Bob"}). +// Execute() +func (t *Tx) BatchUpdate(table, keyColumn string) *BatchUpdateQuery { + return t.Builder().BatchUpdate(table, keyColumn) +} + +// Upsert creates a new UPSERT query within the transaction. +// +// This is a convenience method equivalent to tx.Builder().Upsert(table, values). +// +// Example: +// +// result, err := tx.Upsert("users", map[string]interface{}{ +// "email": "alice@example.com", +// "name": "Alice", +// }).OnConflict("email").DoUpdate("name").Execute() +func (t *Tx) Upsert(table string, values map[string]interface{}) *UpsertQuery { + return t.Builder().Upsert(table, values) +} + +// NewQuery creates a raw SQL query within the transaction. +// +// This is a convenience method equivalent to tx.Builder()-based raw query execution. +// +// Example: +// +// var count int +// err := tx.NewQuery("SELECT COUNT(*) FROM users").Row(&count) +func (t *Tx) NewQuery(query string) *Query { + return &Query{q: t.tx.NewQuery(query)} +} + // ============================================================================ // ModelQuery methods // ============================================================================ @@ -1278,6 +1334,21 @@ func (mq *ModelQuery) Table(name string) *ModelQuery { return &ModelQuery{mq: mq.mq.Table(name)} } +// WithContext sets the context for this model operation. +// +// The context is propagated to the underlying query execution, +// enabling cancellation and deadline support. +// +// Example: +// +// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +// defer cancel() +// err := db.Model(&user).WithContext(ctx).Insert() +func (mq *ModelQuery) WithContext(ctx context.Context) *ModelQuery { + mq.mq.SetContext(ctx) + return mq +} + // ============================================================================ // QueryBuilder Methods // ============================================================================ @@ -1726,19 +1797,14 @@ func (sq *SelectQuery) Having(condition interface{}, args ...interface{}) *Selec return sq } -// Distinct sets whether to select distinct rows. -// When enabled, adds DISTINCT keyword to the SELECT clause to eliminate duplicate rows. -// Multiple calls to Distinct() override previous settings. +// Distinct adds the DISTINCT keyword to the SELECT clause, eliminating duplicate rows. // // Example: // -// db.Builder().Select("category").From("products").Distinct(true).All(&categories) +// db.Builder().Select("category").From("products").Distinct().All(&categories) // // SELECT DISTINCT "category" FROM "products" -// -// db.Builder().Select("*").From("users").Distinct(false).All(&users) -// // SELECT * FROM "users" -func (sq *SelectQuery) Distinct(v bool) *SelectQuery { - sq.sq.Distinct(v) +func (sq *SelectQuery) Distinct() *SelectQuery { + sq.sq.Distinct() return sq } @@ -2148,6 +2214,18 @@ func (uq *UpsertQuery) Execute() (sql.Result, error) { return uq.Build().Execute() } +// ToSQL returns the SQL string and parameters without executing the query. +// This is useful for debugging, logging, or passing the query to another layer. +// +// Example: +// +// sql, params := db.Upsert("users", map[string]interface{}{"id": 1, "name": "Alice"}). +// OnConflict("id").DoUpdate("name").ToSQL() +func (uq *UpsertQuery) ToSQL() (string, []interface{}) { + q := uq.Build() + return q.SQL(), q.Params() +} + // ============================================================================ // BatchInsertQuery Methods // ============================================================================ @@ -2195,6 +2273,18 @@ func (biq *BatchInsertQuery) Execute() (sql.Result, error) { return biq.Build().Execute() } +// ToSQL returns the SQL string and parameters without executing the query. +// This is useful for debugging, logging, or passing the query to another layer. +// +// Example: +// +// sql, params := db.BatchInsert("users", []string{"name", "email"}). +// Values("Alice", "alice@example.com").ToSQL() +func (biq *BatchInsertQuery) ToSQL() (string, []interface{}) { + q := biq.Build() + return q.SQL(), q.Params() +} + // ============================================================================ // BatchUpdateQuery Methods // ============================================================================ @@ -2231,6 +2321,18 @@ func (buq *BatchUpdateQuery) Execute() (sql.Result, error) { return buq.Build().Execute() } +// ToSQL returns the SQL string and parameters without executing the query. +// This is useful for debugging, logging, or passing the query to another layer. +// +// Example: +// +// sql, params := db.BatchUpdate("users", "id"). +// Set(1, map[string]interface{}{"status": 2}).ToSQL() +func (buq *BatchUpdateQuery) ToSQL() (string, []interface{}) { + q := buq.Build() + return q.SQL(), q.Params() +} + // ============================================================================ // Query Methods // ============================================================================ @@ -2373,14 +2475,21 @@ func (q *Query) SQL() string { return q.q.SQL() } -// QueryParams returns the query parameters. -func (q *Query) QueryParams() []interface{} { +// Params returns the query parameters. +func (q *Query) Params() []interface{} { if q.q == nil { return nil } return q.q.Params() } +// QueryParams returns the query parameters. +// +// Deprecated: Use Params instead. +func (q *Query) QueryParams() []interface{} { + return q.Params() +} + // ============================================================================ // Re-export errors and error classification helpers // ============================================================================ @@ -2407,7 +2516,7 @@ var ErrNotFound = core.ErrNotFound // if relica.IsUniqueViolation(err) { // // handle duplicate key // } -var IsUniqueViolation = core.IsUniqueViolation +func IsUniqueViolation(err error) bool { return core.IsUniqueViolation(err) } // IsForeignKeyViolation reports whether err represents a foreign key constraint violation. // Works with PostgreSQL, MySQL, and SQLite. Returns false for nil errors. @@ -2418,7 +2527,7 @@ var IsUniqueViolation = core.IsUniqueViolation // if relica.IsForeignKeyViolation(err) { // // handle missing referenced row // } -var IsForeignKeyViolation = core.IsForeignKeyViolation +func IsForeignKeyViolation(err error) bool { return core.IsForeignKeyViolation(err) } // IsNotNullViolation reports whether err represents a NOT NULL constraint violation. // Works with PostgreSQL, MySQL, and SQLite. Returns false for nil errors. @@ -2429,7 +2538,7 @@ var IsForeignKeyViolation = core.IsForeignKeyViolation // if relica.IsNotNullViolation(err) { // // handle missing required field // } -var IsNotNullViolation = core.IsNotNullViolation +func IsNotNullViolation(err error) bool { return core.IsNotNullViolation(err) } // IsCheckViolation reports whether err represents a CHECK constraint violation. // Works with PostgreSQL, MySQL, and SQLite. Returns false for nil errors. @@ -2440,17 +2549,17 @@ var IsNotNullViolation = core.IsNotNullViolation // if relica.IsCheckViolation(err) { // // handle check constraint failure // } -var IsCheckViolation = core.IsCheckViolation +func IsCheckViolation(err error) bool { return core.IsCheckViolation(err) } // ============================================================================ // Re-export configuration options // ============================================================================ // WithMaxOpenConns sets the maximum number of open connections. -var WithMaxOpenConns = core.WithMaxOpenConns +func WithMaxOpenConns(n int) Option { return core.WithMaxOpenConns(n) } // WithMaxIdleConns sets the maximum number of idle connections. -var WithMaxIdleConns = core.WithMaxIdleConns +func WithMaxIdleConns(n int) Option { return core.WithMaxIdleConns(n) } // WithConnMaxLifetime sets the maximum amount of time a connection may be reused. // Expired connections may be closed lazily before reuse. @@ -2460,7 +2569,7 @@ var WithMaxIdleConns = core.WithMaxIdleConns // // db, err := relica.Open("postgres", dsn, // relica.WithConnMaxLifetime(5*time.Minute)) -var WithConnMaxLifetime = core.WithConnMaxLifetime +func WithConnMaxLifetime(d time.Duration) Option { return core.WithConnMaxLifetime(d) } // WithConnMaxIdleTime sets the maximum amount of time a connection may be idle. // Expired connections may be closed lazily before reuse. @@ -2470,7 +2579,7 @@ var WithConnMaxLifetime = core.WithConnMaxLifetime // // db, err := relica.Open("postgres", dsn, // relica.WithConnMaxIdleTime(1*time.Minute)) -var WithConnMaxIdleTime = core.WithConnMaxIdleTime +func WithConnMaxIdleTime(d time.Duration) Option { return core.WithConnMaxIdleTime(d) } // WithHealthCheck enables periodic health checks on database connections. // The health checker pings the database at the specified interval to detect dead connections. @@ -2480,10 +2589,10 @@ var WithConnMaxIdleTime = core.WithConnMaxIdleTime // // db, err := relica.Open("postgres", dsn, // relica.WithHealthCheck(30*time.Second)) -var WithHealthCheck = core.WithHealthCheck +func WithHealthCheck(interval time.Duration) Option { return core.WithHealthCheck(interval) } // WithStmtCacheCapacity sets the prepared statement cache capacity. -var WithStmtCacheCapacity = core.WithStmtCacheCapacity +func WithStmtCacheCapacity(capacity int) Option { return core.WithStmtCacheCapacity(capacity) } // WithLogger sets the logger for database query logging. // If not set, a NoopLogger is used (zero overhead when logging is disabled). @@ -2494,7 +2603,7 @@ var WithStmtCacheCapacity = core.WithStmtCacheCapacity // logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) // db, err := relica.Open("postgres", dsn, // relica.WithLogger(logger.NewSlogAdapter(logger))) -var WithLogger = core.WithLogger +func WithLogger(l Logger) Option { return core.WithLogger(l) } // WithQueryHook sets a callback function that is invoked after each query execution. // Use this for logging, metrics, distributed tracing, or debugging. @@ -2506,7 +2615,7 @@ var WithLogger = core.WithLogger // relica.WithQueryHook(func(ctx context.Context, e relica.QueryEvent) { // slog.Info("query", "sql", e.SQL, "duration", e.Duration, "err", e.Error) // })) -var WithQueryHook = core.WithQueryHook +func WithQueryHook(hook QueryHook) Option { return core.WithQueryHook(hook) } // WithSensitiveFields sets the list of sensitive field names for parameter masking. // If not set, default sensitive field patterns are used. @@ -2515,7 +2624,7 @@ var WithQueryHook = core.WithQueryHook // // db, err := relica.Open("postgres", dsn, // relica.WithSensitiveFields([]string{"password", "token", "api_key"})) -var WithSensitiveFields = core.WithSensitiveFields +func WithSensitiveFields(fields []string) Option { return core.WithSensitiveFields(fields) } // Logger defines the logging interface for Relica. // Implementations should handle structured logging with key-value pairs. @@ -2528,7 +2637,7 @@ type NoopLogger = logger.NoopLogger type SlogAdapter = logger.SlogAdapter // NewSlogAdapter creates a new logger adapter wrapping an slog.Logger. -var NewSlogAdapter = logger.NewSlogAdapter +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { return logger.NewSlogAdapter(l) } // QueryEvent contains information about an executed query. // This is passed to QueryHook callbacks for logging, metrics, or tracing. @@ -2549,7 +2658,7 @@ type QueryHook = core.QueryHook type Params = core.Params // DetectOperation detects the SQL operation type (SELECT, INSERT, UPDATE, DELETE, UNKNOWN). -var DetectOperation = core.DetectOperation +func DetectOperation(query string) string { return core.DetectOperation(query) } // NullStringMap represents a map of nullable string values scanned from database rows. // Each value is a sql.NullString that can be checked for NULL. @@ -2574,89 +2683,93 @@ type NullStringMap = core.NullStringMap // ============================================================================ // NewExp creates a new raw SQL expression. -var NewExp = core.NewExp +func NewExp(rawSQL string, args ...interface{}) Expression { return core.NewExp(rawSQL, args...) } // Eq creates an equality expression (column = value). -var Eq = core.Eq +func Eq(col string, value interface{}) Expression { return core.Eq(col, value) } // NotEq creates a not-equal expression (column != value). -var NotEq = core.NotEq +func NotEq(col string, value interface{}) Expression { return core.NotEq(col, value) } // GreaterThan creates a greater-than expression (column > value). -var GreaterThan = core.GreaterThan +func GreaterThan(col string, value interface{}) Expression { return core.GreaterThan(col, value) } // LessThan creates a less-than expression (column < value). -var LessThan = core.LessThan +func LessThan(col string, value interface{}) Expression { return core.LessThan(col, value) } // GreaterOrEqual creates a greater-or-equal expression (column >= value). -var GreaterOrEqual = core.GreaterOrEqual +func GreaterOrEqual(col string, value interface{}) Expression { + return core.GreaterOrEqual(col, value) +} // LessOrEqual creates a less-or-equal expression (column <= value). -var LessOrEqual = core.LessOrEqual +func LessOrEqual(col string, value interface{}) Expression { return core.LessOrEqual(col, value) } // In creates an IN expression (column IN (values...)). -var In = core.In +func In(col string, values ...interface{}) Expression { return core.In(col, values...) } // NotIn creates a NOT IN expression (column NOT IN (values...)). -var NotIn = core.NotIn +func NotIn(col string, values ...interface{}) Expression { return core.NotIn(col, values...) } // Between creates a BETWEEN expression (column BETWEEN low AND high). -var Between = core.Between +func Between(col string, from, to interface{}) Expression { return core.Between(col, from, to) } // NotBetween creates a NOT BETWEEN expression. -var NotBetween = core.NotBetween +func NotBetween(col string, from, to interface{}) Expression { + return core.NotBetween(col, from, to) +} // Like creates a LIKE expression with automatic escaping. -var Like = core.Like +func Like(col string, values ...string) *LikeExp { return core.Like(col, values...) } // NotLike creates a NOT LIKE expression. -var NotLike = core.NotLike +func NotLike(col string, values ...string) *LikeExp { return core.NotLike(col, values...) } // OrLike creates a LIKE expression combined with OR. -var OrLike = core.OrLike +func OrLike(col string, values ...string) *LikeExp { return core.OrLike(col, values...) } // OrNotLike creates a NOT LIKE expression combined with OR. -var OrNotLike = core.OrNotLike +func OrNotLike(col string, values ...string) *LikeExp { return core.OrNotLike(col, values...) } // And combines expressions with AND. -var And = core.And +func And(exps ...Expression) Expression { return core.And(exps...) } // Or combines expressions with OR. -var Or = core.Or +func Or(exps ...Expression) Expression { return core.Or(exps...) } // Not negates an expression. -var Not = core.Not +func Not(exp Expression) Expression { return core.Not(exp) } // Exists creates an EXISTS subquery expression. -var Exists = core.Exists +func Exists(exp Expression) Expression { return core.Exists(exp) } // NotExists creates a NOT EXISTS subquery expression. -var NotExists = core.NotExists +func NotExists(exp Expression) Expression { return core.NotExists(exp) } // ============================================================================ // Re-export functional expressions (CASE, COALESCE, NULLIF, etc.) // ============================================================================ // Case creates a simple CASE expression. -var Case = core.Case +func Case(column string) *CaseExp { return core.Case(column) } // CaseWhen creates a searched CASE expression (without column). -var CaseWhen = core.CaseWhen +func CaseWhen() *CaseExp { return core.CaseWhen() } // Coalesce creates a COALESCE expression. -var Coalesce = core.Coalesce +func Coalesce(values ...interface{}) *CoalesceExp { return core.Coalesce(values...) } // NullIf creates a NULLIF expression. -var NullIf = core.NullIf +func NullIf(expr1, expr2 interface{}) *NullIfExp { return core.NullIf(expr1, expr2) } // Greatest creates a GREATEST expression. -var Greatest = core.Greatest +func Greatest(values ...interface{}) *GreatestLeastExp { return core.Greatest(values...) } // Least creates a LEAST expression. -var Least = core.Least +func Least(values ...interface{}) *GreatestLeastExp { return core.Least(values...) } // Concat creates a string concatenation expression. -var Concat = core.Concat +func Concat(values ...interface{}) *ConcatExp { return core.Concat(values...) } // CaseExp represents a SQL CASE expression. type CaseExp = core.CaseExp diff --git a/db_coverage_test.go b/db_coverage_test.go index 2c0295c..1255030 100644 --- a/db_coverage_test.go +++ b/db_coverage_test.go @@ -881,7 +881,7 @@ func TestSelectQuery_Extended(t *testing.T) { var names []string err := db.Builder().Select("name"). From("cover_users"). - Distinct(true). + Distinct(). Column(&names) require.NoError(t, err) // Should have unique names. @@ -892,11 +892,10 @@ func TestSelectQuery_Extended(t *testing.T) { assert.Equal(t, 1, seen["Alice"], "Alice should appear once with DISTINCT") }) - t.Run("Distinct false includes all rows", func(t *testing.T) { + t.Run("Without distinct includes all rows", func(t *testing.T) { var names []string err := db.Builder().Select("name"). From("cover_users"). - Distinct(false). Column(&names) require.NoError(t, err) assert.GreaterOrEqual(t, len(names), 3) diff --git a/internal/core/builder.go b/internal/core/builder.go index a69ad27..734ae0d 100644 --- a/internal/core/builder.go +++ b/internal/core/builder.go @@ -599,19 +599,14 @@ func (sq *SelectQuery) WithRecursive(name string, query *SelectQuery) *SelectQue return sq } -// Distinct sets whether to select distinct rows. -// When enabled, adds DISTINCT keyword to the SELECT clause to eliminate duplicate rows. -// Multiple calls to Distinct() override previous settings. +// Distinct adds the DISTINCT keyword to the SELECT clause, eliminating duplicate rows. // // Example: // -// db.Builder().Select("category").From("products").Distinct(true).All(&categories) +// db.Builder().Select("category").From("products").Distinct().All(&categories) // // SELECT DISTINCT "category" FROM "products" -// -// db.Builder().Select("*").From("users").Distinct(false).All(&users) -// // SELECT * FROM "users" -func (sq *SelectQuery) Distinct(v bool) *SelectQuery { - sq.distinct = v +func (sq *SelectQuery) Distinct() *SelectQuery { + sq.distinct = true return sq } diff --git a/internal/core/db.go b/internal/core/db.go index e3686bd..f8287fd 100644 --- a/internal/core/db.go +++ b/internal/core/db.go @@ -332,6 +332,16 @@ func (tx *Tx) Rollback() error { return tx.tx.Rollback() } +// NewQuery creates a raw SQL query that executes within the transaction. +func (tx *Tx) NewQuery(query string) *Query { + return &Query{ + sql: query, + db: tx.builder.db, + tx: tx.tx, + ctx: tx.ctx, + } +} + // Transactional executes f within a transaction with automatic commit/rollback. // If f returns an error, the transaction is rolled back and the error is returned. // If f panics, the transaction is rolled back and the panic is re-raised. diff --git a/internal/core/distinct_demo_test.go b/internal/core/distinct_demo_test.go index c09a5b9..52d1f47 100644 --- a/internal/core/distinct_demo_test.go +++ b/internal/core/distinct_demo_test.go @@ -16,7 +16,7 @@ func TestDistinct_Demo(t *testing.T) { fmt.Println("\n1. Basic DISTINCT:") q1 := qb.Select("category"). From("products"). - Distinct(true). + Distinct(). Build() fmt.Printf(" SQL: %s\n", q1.sql) fmt.Printf(" Expected: SELECT DISTINCT \"category\" FROM \"products\"\n") @@ -25,7 +25,7 @@ func TestDistinct_Demo(t *testing.T) { fmt.Println("\n2. DISTINCT with multiple columns:") q2 := qb.Select("country", "city"). From("locations"). - Distinct(true). + Distinct(). Build() fmt.Printf(" SQL: %s\n", q2.sql) fmt.Printf(" Expected: SELECT DISTINCT \"country\", \"city\" FROM \"locations\"\n") @@ -35,7 +35,7 @@ func TestDistinct_Demo(t *testing.T) { q3 := qb.Select("status"). From("orders"). Where("total > ?", 100). - Distinct(true). + Distinct(). Build() fmt.Printf(" SQL: %s\n", q3.sql) fmt.Printf(" Params: %v\n", q3.params) @@ -47,7 +47,7 @@ func TestDistinct_Demo(t *testing.T) { From("users u"). InnerJoin("orders o", "o.user_id = u.id"). Where("o.status = ?", "completed"). - Distinct(true). + Distinct(). OrderBy("u.country ASC"). Limit(10). Build() @@ -62,24 +62,14 @@ func TestDistinct_Demo(t *testing.T) { fmt.Printf(" SQL: %s\n", q5.sql) fmt.Printf(" Expected: SELECT \"category\" FROM \"products\"\n") - // Example 6: Explicitly disable DISTINCT. - fmt.Println("\n6. Explicitly disable DISTINCT:") + // Example 6: DISTINCT is always enabled when called. + fmt.Println("\n6. DISTINCT is always enabled when Distinct() is called:") q6 := qb.Select("name"). From("users"). - Distinct(false). + Distinct(). Build() fmt.Printf(" SQL: %s\n", q6.sql) - fmt.Printf(" Expected: SELECT \"name\" FROM \"users\"\n") - - // Example 7: Toggle DISTINCT (last call wins). - fmt.Println("\n7. Toggle DISTINCT (last call wins):") - q7 := qb.Select("role"). - From("users"). - Distinct(true). - Distinct(false). - Build() - fmt.Printf(" SQL: %s\n", q7.sql) - fmt.Printf(" Note: DISTINCT(false) overrides DISTINCT(true)\n") + fmt.Printf(" Expected: SELECT DISTINCT \"name\" FROM \"users\"\n") fmt.Println("\n=== Demo Complete ===") } diff --git a/internal/core/distinct_test.go b/internal/core/distinct_test.go index d8dc5c4..65c79da 100644 --- a/internal/core/distinct_test.go +++ b/internal/core/distinct_test.go @@ -7,14 +7,14 @@ import ( "github.com/stretchr/testify/require" ) -// TestSelectQuery_Distinct_True tests DISTINCT with true flag. +// TestSelectQuery_Distinct tests that Distinct() adds the DISTINCT keyword. func TestSelectQuery_Distinct_True(t *testing.T) { db := mockDB("postgres") qb := &QueryBuilder{db: db} query := qb.Select("category"). From("products"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -25,14 +25,13 @@ func TestSelectQuery_Distinct_True(t *testing.T) { assert.Empty(t, q.params, "DISTINCT should have no params") } -// TestSelectQuery_Distinct_False tests DISTINCT with false flag. +// TestSelectQuery_Distinct_False tests that without Distinct() there is no DISTINCT keyword. func TestSelectQuery_Distinct_False(t *testing.T) { db := mockDB("postgres") qb := &QueryBuilder{db: db} query := qb.Select("name"). - From("users"). - Distinct(false) + From("users") q := query.Build() require.NotNil(t, q) @@ -66,7 +65,7 @@ func TestSelectQuery_Distinct_MultipleColumns(t *testing.T) { query := qb.Select("country", "city"). From("locations"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -82,7 +81,7 @@ func TestSelectQuery_Distinct_Wildcard(t *testing.T) { query := qb.Select("*"). From("logs"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -99,7 +98,7 @@ func TestSelectQuery_Distinct_WithWhere(t *testing.T) { query := qb.Select("status"). From("orders"). Where("total > ?", 100). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -118,7 +117,7 @@ func TestSelectQuery_Distinct_WithOrderBy(t *testing.T) { query := qb.Select("department"). From("employees"). - Distinct(true). + Distinct(). OrderBy("department ASC") q := query.Build() @@ -135,7 +134,7 @@ func TestSelectQuery_Distinct_WithLimit(t *testing.T) { query := qb.Select("tag"). From("posts"). - Distinct(true). + Distinct(). Limit(10) q := query.Build() @@ -153,7 +152,7 @@ func TestSelectQuery_Distinct_WithJoin(t *testing.T) { query := qb.Select("u.country"). From("users u"). InnerJoin("orders o", "o.user_id = u.id"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -162,7 +161,7 @@ func TestSelectQuery_Distinct_WithJoin(t *testing.T) { assert.Contains(t, q.sql, `INNER JOIN`) } -// TestSelectQuery_Distinct_Toggle tests toggling DISTINCT on and off. +// TestSelectQuery_Distinct_Toggle tests that Distinct() enables DISTINCT and a fresh query without it has none. func TestSelectQuery_Distinct_Toggle(t *testing.T) { db := mockDB("postgres") qb := &QueryBuilder{db: db} @@ -170,17 +169,15 @@ func TestSelectQuery_Distinct_Toggle(t *testing.T) { // Enable DISTINCT query := qb.Select("role"). From("users"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) assert.Contains(t, q.sql, "DISTINCT") - // Disable DISTINCT (override) + // Without Distinct() — no DISTINCT keyword query2 := qb.Select("role"). - From("users"). - Distinct(true). - Distinct(false) + From("users") q2 := query2.Build() require.NotNil(t, q2) @@ -195,7 +192,7 @@ func TestSelectQuery_Distinct_Chainable(t *testing.T) { // Verify method chaining works query := qb.Select("type"). From("items"). - Distinct(true). + Distinct(). Where("active = ?", true). OrderBy("type"). Limit(5) @@ -216,7 +213,7 @@ func TestSelectQuery_Distinct_WithAggregate(t *testing.T) { query := qb.Select("COUNT(DISTINCT user_id)"). From("events"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -232,7 +229,7 @@ func TestSelectQuery_Distinct_PostgreSQL(t *testing.T) { query := qb.Select("category"). From("products"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -249,7 +246,7 @@ func TestSelectQuery_Distinct_MySQL(t *testing.T) { query := qb.Select("brand"). From("products"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -266,7 +263,7 @@ func TestSelectQuery_Distinct_SQLite(t *testing.T) { query := qb.Select("color"). From("items"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -286,7 +283,7 @@ func TestSelectQuery_Distinct_ComplexQuery(t *testing.T) { InnerJoin("orders o", "o.user_id = u.id"). Where("o.status = ?", "completed"). Where("o.total > ?", 50). - Distinct(true). + Distinct(). OrderBy("u.country ASC", "u.city ASC"). Limit(100). Offset(20) @@ -333,7 +330,7 @@ func TestSelectQuery_Distinct_WithGroupBy(t *testing.T) { query := qb.Select("category", "COUNT(*) as cnt"). From("products"). GroupBy("category"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -350,7 +347,7 @@ func TestSelectQuery_Distinct_WithSelectExpr(t *testing.T) { query := qb.Select("name"). SelectExpr("UPPER(email) as upper_email"). From("users"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -366,7 +363,7 @@ func TestSelectQuery_Distinct_EmptySelect(t *testing.T) { query := qb.Select(). From("users"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) @@ -383,7 +380,7 @@ func TestSelectQuery_Distinct_WithAlias(t *testing.T) { query := qb.Select("status as order_status"). From("orders"). - Distinct(true) + Distinct() q := query.Build() require.NotNil(t, q) diff --git a/internal/core/model_query.go b/internal/core/model_query.go index d379934..6915807 100644 --- a/internal/core/model_query.go +++ b/internal/core/model_query.go @@ -2,6 +2,7 @@ package core import ( + "context" "database/sql" "errors" "reflect" @@ -22,6 +23,14 @@ type ModelQuery struct { model interface{} table string exclude map[string]bool + ctx context.Context // nil means use background context +} + +// SetContext sets the context for this ModelQuery. +// Returns the same ModelQuery to allow further configuration. +func (mq *ModelQuery) SetContext(ctx context.Context) *ModelQuery { + mq.ctx = ctx + return mq } // Model creates a new ModelQuery for the given struct. @@ -177,10 +186,11 @@ func (mq *ModelQuery) Insert(attrs ...string) error { } } - // Create builder with transaction context if applicable. + // Create builder with transaction/query context if applicable. qb := &QueryBuilder{ - db: mq.db, - tx: mq.tx, + db: mq.db, + tx: mq.tx, + ctx: mq.ctx, } // Build INSERT query. @@ -376,10 +386,11 @@ func (mq *ModelQuery) Update(attrs ...string) error { delete(filtered, col) } - // Create builder with transaction context if applicable. + // Create builder with transaction/query context if applicable. qb := &QueryBuilder{ - db: mq.db, - tx: mq.tx, + db: mq.db, + tx: mq.tx, + ctx: mq.ctx, } // Build UPDATE query with WHERE clause for all PK columns. @@ -439,10 +450,11 @@ func (mq *ModelQuery) Upsert(fields ...string) error { // Build update columns: either specified fields or all non-PK fields. updateCols := mq.buildUpsertUpdateCols(dataMap, pkCols, fields) - // Create builder with transaction context if applicable. + // Create builder with transaction/query context if applicable. qb := &QueryBuilder{ - db: mq.db, - tx: mq.tx, + db: mq.db, + tx: mq.tx, + ctx: mq.ctx, } upsertQuery := qb.Upsert(mq.table, dataMap). @@ -567,10 +579,11 @@ func (mq *ModelQuery) UpdateChanged(original interface{}) error { return errors.New("model: primary key not found") } - // Create builder with transaction context if applicable. + // Create builder with transaction/query context if applicable. qb := &QueryBuilder{ - db: mq.db, - tx: mq.tx, + db: mq.db, + tx: mq.tx, + ctx: mq.ctx, } updateQuery := qb.Update(mq.table).Set(changed) @@ -699,10 +712,11 @@ func (mq *ModelQuery) Delete() error { return errors.New("model: primary key not found") } - // Create builder with transaction context if applicable. + // Create builder with transaction/query context if applicable. qb := &QueryBuilder{ - db: mq.db, - tx: mq.tx, + db: mq.db, + tx: mq.tx, + ctx: mq.ctx, } // Build DELETE query with WHERE clause for all PK columns. From ef26930175832ad8359c0e2c8a75ce4d37b152ef Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 3 Jul 2026 17:48:52 +0300 Subject: [PATCH 2/2] docs: add CHANGELOG entry for v0.12.0 breaking API changes --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c03735..586a9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - Unreleased + +### Breaking Changes (pre-v1.0 API cleanup) + +- **`var` → `func`** — All 32 exported expression builders and error helpers are now proper functions (`func Eq(col string, value interface{}) Expression` instead of `var Eq = core.Eq`). Improves godoc, prevents accidental reassignment. `ErrNotFound` remains `var` (sentinel error value). +- **`Distinct(bool)` → `Distinct()`** — No parameter, always enables DISTINCT. Remove `Distinct(false)` calls (omit the method instead). +- **`QueryParams()` deprecated** — Use `Params()` instead. `QueryParams()` still works but marked deprecated. + +### Added + +- **`Tx.BatchInsert()`** — Batch insert operations now available in transactions +- **`Tx.BatchUpdate()`** — Batch update operations now available in transactions +- **`Tx.Upsert()`** — Upsert operations now available in transactions +- **`Tx.NewQuery()`** — Raw SQL queries now available in transactions +- **`ToSQL()`** on `UpsertQuery`, `BatchInsertQuery`, `BatchUpdateQuery` — All 6 query types now support SQL preview for debugging +- **`ModelQuery.WithContext(ctx)`** — Per-operation context support on Model API +- **`Query.Params()`** — Canonical method for retrieving query parameters + +--- + ## [0.11.2] - Unreleased ### Security