Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - 2026-02-25

### Added

- **sqlbuilder** — New `sqlbuilder` package: fluent, type-safe SQL query builder with multi-dialect support (PostgreSQL, MySQL, SQLite)
- **sqlbuilder** — `Select`, `Insert`, `Update`, `Delete` builders with chainable API and `Build() (string, []any)` terminal method
- **sqlbuilder** — Dialect support: `Postgres` (default, `$1, $2, ...`), `MySQL` and `SQLite` (`?` placeholders) via `SetDialect(d)` or `SelectWith(d, ...)` / `InsertWith(d, ...)` / `UpdateWith(d, ...)` / `DeleteWith(d, ...)` constructors
- **sqlbuilder** — SELECT: `Distinct`, `Column`, `Columns`, `ColumnExpr`, `From`, `FromAlias`, `FromSubquery`
- **sqlbuilder** — JOIN support: `Join`, `LeftJoin`, `RightJoin`, `FullJoin`, `CrossJoin` with parameterized ON clauses
- **sqlbuilder** — WHERE conditions: `Where`, `WhereEq`, `WhereNeq`, `WhereGt`, `WhereGte`, `WhereLt`, `WhereLte`, `WhereLike`, `WhereILike`, `WhereIn`, `WhereNotIn`, `WhereBetween`, `WhereNull`, `WhereNotNull`, `WhereExists`, `WhereNotExists`, `WhereOr`, `WhereInSubquery`, `WhereNotInSubquery` — available on Select, Update, and Delete builders
- **sqlbuilder** — Automatic placeholder rebasing: each `Where` call uses `$1`-relative numbering, globally rebased at `Build()` time
- **sqlbuilder** — `GroupBy`, `Having`, `OrderBy`, `OrderByAsc`, `OrderByDesc`, `OrderByExpr`, `Limit`, `Offset`
- **sqlbuilder** — Row-level locking: `ForUpdate`, `ForShare`, `SkipLocked`, `NoWait`
- **sqlbuilder** — Set operations: `Union`, `UnionAll`, `Intersect`, `Except`
- **sqlbuilder** — CTEs: `With`, `WithRecursive`, `WithSelect`, `WithRecursiveSelect` on all builders
- **sqlbuilder** — INSERT: `Columns`, `Values`, `ValueMap`, `BatchValues`, `FromSelect`
- **sqlbuilder** — Upsert: `OnConflictDoNothing`, `OnConflictUpdate`, `OnConflictUpdateExpr`
- **sqlbuilder** — UPDATE: `Set`, `SetExpr`, `SetMap`, `Increment`, `Decrement`, multi-table `From`
- **sqlbuilder** — DELETE: `Using` for multi-table deletes
- **sqlbuilder** — `Returning` clause on Insert, Update, and Delete builders
- **sqlbuilder** — Expression helpers: `Raw`, `RawExpr`, `Count`, `CountDistinct`, `Sum`, `Avg`, `Min`, `Max`, `Expr.As(alias)`
- **sqlbuilder** — Request integration: `ApplyPagination`, `ApplySort`, `ApplyFilters` bridge `request` package types to query conditions
- **sqlbuilder** — `When(cond, fn)` conditional builder and `Clone()` deep copy on all builders
- **sqlbuilder** — All builders expose `Build()`, `MustBuild()`, `Query()`, and `String()` terminal methods

## [0.6.0] - 2026-02-24

### Added
Expand Down
167 changes: 166 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A production-ready Go toolkit for building REST APIs. Zero mandatory dependencie
- **`router`** — Route grouping with `.Get()`/`.Post()` method helpers, prefix groups, and per-group middleware on top of `http.ServeMux`
- **`server`** — Graceful shutdown wrapper with signal handling, lifecycle hooks, and TLS support
- **`health`** — Health check endpoint builder with dependency checks, timeouts, and liveness/readiness probes
- **`sqlbuilder`** — Fluent SQL query builder for PostgreSQL, MySQL, and SQLite with JOINs, CTEs, UNION, upsert, and `request` package integration
- **`apitest`** — Fluent test helpers for recording and asserting HTTP handler responses

## Install
Expand Down Expand Up @@ -558,14 +559,177 @@ fmt.Println(resp.Status) // "healthy", "degraded", or "unhealthy"
"status": "healthy",
"checks": {
"postgres": { "status": "healthy", "duration_ms": 2 },
"redis": { "status": "healthy", "duration_ms": 1 }
"redis": { "status": "healthy", "duration_ms": 1 }
},
"timestamp": 1700000000
},
"timestamp": 1700000000
}
```

### sqlbuilder

Fluent SQL query builder for PostgreSQL, MySQL, and SQLite. Produces `(string, []any)` pairs — no `database/sql` dependency.

```go
import "github.com/KARTIKrocks/apikit/sqlbuilder"

// --- SELECT ---
sql, args := sqlbuilder.Select("id", "name", "email").
From("users").
Where("active = $1", true).
OrderBy("name ASC").
Limit(20).
Build()
// sql: "SELECT id, name, email FROM users WHERE active = $1 ORDER BY name ASC LIMIT 20"
// args: [true]

// Convenience Where helpers — no placeholder syntax needed
sql, args := sqlbuilder.Select("id").From("users").
WhereEq("status", "active").
WhereGt("age", 18).
WhereLike("name", "A%").
Build()
// sql: "SELECT id FROM users WHERE status = $1 AND age > $2 AND name LIKE $3"
// args: ["active", 18, "A%"]
// Also: WhereNeq, WhereGte, WhereLt, WhereLte, WhereILike

// OrderBy helpers
sql, _ = sqlbuilder.Select("id").From("users").
OrderByAsc("name").
OrderByDesc("created_at").
Build()
// sql: "SELECT id FROM users ORDER BY name ASC, created_at DESC"

// Placeholder rebasing — each Where uses $1-relative numbering
sql, args = sqlbuilder.Select("id").From("users").
Where("status = $1", "active").
Where("age > $1", 18).
Build()
// sql: "SELECT id FROM users WHERE status = $1 AND age > $2"
// args: ["active", 18]

// JOINs, GROUP BY, HAVING
sql, args := sqlbuilder.Select("u.id", "COUNT(o.id) as orders").
From("users u").
LeftJoin("orders o", "o.user_id = u.id").
Where("u.active = $1", true).
GroupBy("u.id").
Having("COUNT(o.id) > $1", 5).
Build()

// Aggregate helpers
sql, _ = sqlbuilder.SelectExpr(
sqlbuilder.Count("*").As("total"),
sqlbuilder.Avg("price").As("avg_price"),
).From("products").Build()
// sql: "SELECT COUNT(*) AS total, AVG(price) AS avg_price FROM products"

// Subquery conditions
sub := sqlbuilder.Select("user_id").From("orders").Where("total > $1", 100)
sql, args = sqlbuilder.Select("id", "name").From("users").
WhereInSubquery("id", sub).
Build()
// sql: "SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > $1)"
// args: [100]

// Subqueries, CTEs, UNION, FOR UPDATE
sql, _ := sqlbuilder.Select("id").From("users").ForUpdate().SkipLocked().Build()
q1 := sqlbuilder.Select("id").From("users")
q2 := sqlbuilder.Select("id").From("admins")
sql, _ = q1.Union(q2).Build()

// Conditional building — only applies the clause when the condition is true
sql, args = sqlbuilder.Select("id").From("users").
When(onlyActive, func(s *sqlbuilder.SelectBuilder) {
s.Where("active = $1", true)
}).Build()

// Clone for safe reuse — mutations to the clone don't affect the original
base := sqlbuilder.Select("id", "name").From("users").Where("active = $1", true)
adminQuery := base.Clone().Where("role = $1", "admin")
userQuery := base.Clone().Where("role = $1", "user")

// --- INSERT ---
sql, args := sqlbuilder.Insert("users").
Columns("name", "email").
Values("Alice", "alice@example.com").
Returning("id").
Build()

// Batch insert
sql, args := sqlbuilder.Insert("users").
Columns("name", "email").
Values("Alice", "alice@example.com").
Values("Bob", "bob@example.com").
Build()

// Upsert (ON CONFLICT)
sql, args := sqlbuilder.Insert("users").
Columns("email", "name").
Values("alice@example.com", "Alice").
OnConflictUpdate([]string{"email"}, map[string]any{"name": "Alice Updated"}).
Build()

// --- UPDATE ---
sql, args := sqlbuilder.Update("users").
Set("name", "Bob").
SetExpr("updated_at", sqlbuilder.Raw("NOW()")).
WhereEq("id", 1).
Build()

// Increment / Decrement
sql, args = sqlbuilder.Update("products").
Increment("view_count", 1).
WhereEq("id", 42).
Build()
// sql: "UPDATE products SET view_count = view_count + $1 WHERE id = $2"
// args: [1, 42]

// --- DELETE ---
sql, args := sqlbuilder.Delete("users").
WhereEq("id", 1).
Returning("id", "name").
Build()

// --- MySQL / SQLite dialect ---
sql, args = sqlbuilder.Select("id", "name").
From("users").
WhereEq("active", true).
WhereGt("age", 18).
SetDialect(sqlbuilder.MySQL). // or sqlbuilder.SQLite
Build()
// sql: "SELECT id, name FROM users WHERE active = ? AND age > ?"
// args: [true, 18]

// Dialect-first constructors
sql, args = sqlbuilder.SelectWith(sqlbuilder.MySQL, "id").
From("users").WhereEq("id", 1).Build()
// sql: "SELECT id FROM users WHERE id = ?"

sql, args = sqlbuilder.InsertWith(sqlbuilder.MySQL, "users").
Columns("name", "email").
Values("Alice", "alice@example.com").
Build()
// sql: "INSERT INTO users (name, email) VALUES (?, ?)"

// --- Integration with request package ---
pg, _ := request.Paginate(r)
sorts, _ := request.ParseSort(r, sortCfg)
filters, _ := request.ParseFilters(r, filterCfg)

cols := map[string]string{"name": "u.name", "created_at": "u.created_at"}

sql, args := sqlbuilder.Select("u.id", "u.name", "u.email").
From("users u").
LeftJoin("profiles p", "p.user_id = u.id").
Where("u.active = $1", true).
ApplyFilters(filters, cols).
ApplySort(sorts, cols).
ApplyPagination(pg).
Build()
```

### apitest

Fluent test helpers for building requests and asserting responses against your handlers.
Expand Down Expand Up @@ -616,6 +780,7 @@ fmt.Println(env.Success, env.Message)
## Roadmap

- [x] `health` — Health check endpoint builder with dependency checks
- [x] `sqlbuilder` — Fluent SQL query builder with request package integration
- [ ] `ctxutil` — Typed context helpers
- [ ] `observe` — OpenTelemetry integration

Expand Down
60 changes: 60 additions & 0 deletions sqlbuilder/bugs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package sqlbuilder

import (
"testing"
)

// Bug 1: ValueMap ignores existing column order.
// If Columns() was called first, ValueMap extracts values in its own sorted key order,
// which may not match the column order, causing silent value/column misalignment.
func TestBug_ValueMapIgnoresColumnOrder(t *testing.T) {
sql, args := Insert("t").
Columns("b", "a").
ValueMap(map[string]any{"a": 1, "b": 2}).
Build()
// Columns are (b, a), so values should be (2, 1) to match
expectSQL(t, "INSERT INTO t (b, a) VALUES ($1, $2)", sql)
expectArgs(t, []any{2, 1}, args)
}

// Bug 2: OnConflictUpdateExpr with no-arg expressions doesn't append sentinel row.
// Build() assumes last row is conflict args and eats a real data row.
func TestBug_OnConflictUpdateExprNoArgs(t *testing.T) {
sql, args := Insert("users").
Columns("email", "name").
Values("alice@example.com", "Alice").
OnConflictUpdateExpr(
[]string{"email"},
map[string]Expr{"name": Raw("EXCLUDED.name")},
).
Build()
expectSQL(t, "INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name", sql)
expectArgs(t, []any{"alice@example.com", "Alice"}, args)
}

// Bug 3: FromSubquery placeholders are not rebased when CTE args exist.
// The subquery SQL is baked into s.from at chain time, never rebased at Build time.
func TestBug_FromSubqueryWithCTE(t *testing.T) {
cteQ := Query{SQL: "SELECT id FROM source WHERE x = $1", Args: []any{"a"}}
sub := Select("id").From("t").Where("y = $1", "b")

sql, args := Select("id").
With("c", cteQ).
FromSubquery(sub, "s").
Build()
// CTE uses $1 for "a", subquery should use $2 for "b"
expectSQL(t, "WITH c AS (SELECT id FROM source WHERE x = $1) SELECT id FROM (SELECT id FROM t WHERE y = $2) s", sql)
expectArgs(t, []any{"a", "b"}, args)
}

// Bug 4: FromSubquery with ColumnExpr args — subquery placeholders not rebased.
func TestBug_FromSubqueryAfterColumnExprArgs(t *testing.T) {
sub := Select("id").From("t").Where("x = $1", "val")

sql, args := SelectExpr(RawExpr("COALESCE($1, 'default')", "test")).
FromSubquery(sub, "s").
Build()
// Column expr uses $1 for "test", subquery should use $2 for "val"
expectSQL(t, "SELECT COALESCE($1, 'default') FROM (SELECT id FROM t WHERE x = $2) s", sql)
expectArgs(t, []any{"test", "val"}, args)
}
Loading
Loading