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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
All notable changes are recorded here. Versions follow [SemVer](https://semver.org/).
Pre-`v1.0.0` releases may include breaking changes between minor versions.

## Unreleased

Bug sweep (fix/bug-sweep-2026-09). Every fix ships with a regression test.

### Fixed
- **`orm` — `sql.Scanner` fields (`sql.NullString`, `*sql.NullString`, ...) failed to hydrate**; numeric columns read into `string` fields became a rune; textual numbers/booleans (MySQL text protocol) failed for float/bool/uint fields.
- **`orm` — `AfterFind` hook was never invoked.**
- **`orm` — `Save` never inserted models with a caller-assigned non-auto-increment key** (UUID/string PK); models without a PK panicked.
- **`orm` — `*Struct` relation fields without a `relation` tag were persisted as columns**, so `Save` failed (`no column named author`).
- **`orm` — `Paginate` and `Chunk` ignored `With(...)`.**
- **`orm` — soft-delete scope / `Chunk` cursor bound only to the last `OrWhere` branch**, leaking trashed rows and looping forever in `Chunk`.
- **`orm` — cast `ToDB` errors were swallowed.**
- **`relations` — NULL columns and mismatched key types (uint64 vs int64/[]byte) broke eager loading.**
- **`query` — `Offset` without `Limit` was a syntax error on SQLite/MySQL; `WhereIn` rejected slice types other than a fixed list; `Where(col, nil)` compiled to `= NULL`; `Distinct().Count()` counted all rows; aggregates with `Offset` returned `sql.ErrNoRows`; empty nested groups rendered `()`; Postgres placeholders ignored `Join` args.** New `Builder.WrapWheres()`.
- **`migrations` — Postgres advisory lock could be released on a different pooled session** (lock leaked, next migrator blocked forever) and ignored the timeout.
- **`cli` — `lago migrate` never saw project migrations.** `lago init`/`lago new` now scaffold `cmd/lago/main.go`; both `lago` and `artisan` re-run it for registry-dependent commands.
- **`adapters/gin` — `X-DB-Query-Count` was always 0 and set after the body was written.** New `database.Connection.OnQuery` hook.
- **`web` — `CORSWithConfig` ignored an explicit `AllowedHeaders` list.**

## v0.26.0 — 2026-06-25

Production-hardening release. A fleet of adversarial test agents (load, fuzz,
Expand Down
26 changes: 26 additions & 0 deletions adapters/gin/lagogin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,32 @@ func TestQueryLogHeader(t *testing.T) {
}
}

// Real queries never reached the counter (only manual ObserveQuery did), so
// the header was always 0; it must count exactly this request's queries.
func TestQueryLogCountsRealQueries(t *testing.T) {
conn := newTestConn(t)

r := gin.New()
r.Use(lagogin.QueryLogN(conn, 1000)) // no explicit Instrument call
r.GET("/q", lagogin.H(func(c *lagogin.Ctx) (any, error) {
_, _ = orm.Query[User](conn).Count(c.Ctx())
var users []User
_ = orm.Query[User](conn).Limit(1).Get(c.Ctx(), &users)
// Queries outside the request context are not attributed to it.
_, _ = orm.Query[User](conn).Count(context.Background())
return "ok", nil
}))
w := do(r, "GET", "/q", nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
// Result().Header is the header as sent; w.Header() would also show
// values set after the body was written, which never reach the client.
if got := w.Result().Header.Get("X-DB-Query-Count"); got != "2" {
t.Fatalf("X-DB-Query-Count on the wire = %q, want 2", got)
}
}

// --- 7. OpenAPI generation ---------------------------------------------

func TestOpenAPIContainsResourcePaths(t *testing.T) {
Expand Down
81 changes: 68 additions & 13 deletions adapters/gin/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ func RequestTimeout(d time.Duration) gin.HandlerFunc {
// If the count crosses threshold (20 by default), a WARN is logged with the
// request path — a cheap N+1 detector for dev environments.
//
// Requires the connection to be passed through Instrument() once at startup:
// Queries are counted per request: those executed with the request's context
// (c.Ctx() / c.Request.Context()) on conn, plus manual ObserveQuery calls.
// QueryLog instruments conn itself; calling Instrument() first is optional:
//
// conn = lagogin.Instrument(conn)
// r.Use(lagogin.QueryLog(conn))
Expand All @@ -118,29 +120,78 @@ func QueryLogN(conn *database.Connection, threshold int) gin.HandlerFunc {
return queryLogWith(conn, threshold)
}

// queryCountKey carries the per-request query counter in the request context.
type queryCountKey struct{}

func queryLogWith(conn *database.Connection, threshold int) gin.HandlerFunc {
Instrument(conn)
return func(c *gin.Context) {
n := new(atomic.Int64)
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), queryCountKey{}, n))
before := globalQueryCount(conn)
c.Next()
count := globalQueryCount(conn) - before
if count < 0 {
count = 0
counted := func() int64 {
if v := n.Load() + globalQueryCount(conn) - before; v > 0 {
return v
}
return 0
}
c.Writer.Header().Set("X-DB-Query-Count", strconv.FormatInt(count, 10))
// Headers set after the handler wrote the body never reach the
// client, so stamp the header when the status line is written.
w := &queryCountWriter{ResponseWriter: c.Writer, count: counted}
c.Writer = w
c.Next()
w.stamp()
count := counted()
if int(count) > threshold && conn.Log != nil {
conn.Log.Warnf("lagogin: %d queries on %s %s (threshold %d) — possible N+1",
count, c.Request.Method, c.Request.URL.Path, threshold)
}
}
}

// Instrument enables per-connection query counting for QueryLog. Call once
// at startup before installing the middleware. The returned connection is
// the same pointer — Instrument only registers it with the global counter
// table and replaces conn.Log with a counting wrapper.
// queryCountWriter sets X-DB-Query-Count right before the response header
// is committed.
type queryCountWriter struct {
gin.ResponseWriter
count func() int64
stamped bool
}

func (w *queryCountWriter) stamp() {
if w.stamped || w.ResponseWriter.Written() {
return
}
w.stamped = true
w.Header().Set("X-DB-Query-Count", strconv.FormatInt(w.count(), 10))
}

func (w *queryCountWriter) WriteHeader(code int) {
w.stamp()
w.ResponseWriter.WriteHeader(code)
}

func (w *queryCountWriter) WriteHeaderNow() {
w.stamp()
w.ResponseWriter.WriteHeaderNow()
}

func (w *queryCountWriter) Write(b []byte) (int, error) {
w.stamp()
return w.ResponseWriter.Write(b)
}

func (w *queryCountWriter) WriteString(s string) (int, error) {
w.stamp()
return w.ResponseWriter.WriteString(s)
}

// Instrument enables query counting for QueryLog. It is idempotent and
// returns the same pointer: it registers conn with the counter table and
// installs a database query hook that bumps the counter of the request whose
// context the statement ran with. Logging settings are left untouched.
//
// The wrapper delegates Info/Warn/Error/SQL/SlowSQL to the original logger,
// so SQL tracing and slow-query reporting continue to work unchanged.
// Previously nothing but manual ObserveQuery calls fed the counter, so
// X-DB-Query-Count was always 0 for real traffic.
func Instrument(conn *database.Connection) *database.Connection {
if conn == nil {
return nil
Expand All @@ -151,7 +202,11 @@ func Instrument(conn *database.Connection) *database.Connection {
return conn
}
counters[conn] = new(atomic.Int64)
conn.Config.LogQueries = true
conn.OnQuery(func(ctx context.Context, _ string, _ []any, _ time.Duration, _ error) {
if n, ok := ctx.Value(queryCountKey{}).(*atomic.Int64); ok {
n.Add(1)
}
})
return conn
}

Expand Down
6 changes: 3 additions & 3 deletions adapters/websocket/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
// Architecture:
//
// - Hub — the per-app singleton. Tracks all live
// connections by ID and by channel (Laravel "room").
// connections by ID and by channel (Laravel "room").
// - Connection — a single open WebSocket; sends are non-blocking
// with a bounded outbox.
// with a bounded outbox.
// - Handler — http.Handler that performs the WebSocket
// handshake and registers the connection on the Hub.
// handshake and registers the connection on the Hub.
//
// Usage:
//
Expand Down
4 changes: 2 additions & 2 deletions admin/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func buildField(sf reflect.StructField) (Field, bool) {
f.IsUpdatedAt = true
}
case "DeletedAt":
if sf.Type == timeType || sf.Type == reflect.PtrTo(timeType) {
if sf.Type == timeType || sf.Type == reflect.PointerTo(timeType) {
f.IsDeletedAt = true
}
}
Expand Down Expand Up @@ -134,7 +134,7 @@ func buildField(sf reflect.StructField) (Field, bool) {

// kindOf maps a Go type to a form-input classification.
func kindOf(t reflect.Type) string {
if t == timeType || t == reflect.PtrTo(timeType) {
if t == timeType || t == reflect.PointerTo(timeType) {
return "datetime"
}
switch indirectType(t).Kind() {
Expand Down
6 changes: 3 additions & 3 deletions broadcasting/broadcasting.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
//
// Compared to events:
// - events — synchronous, in-process, typed via generics. For
// aggregating domain reactions during a single request.
// aggregating domain reactions during a single request.
// - broadcasting — many-to-many fan-out across processes (when paired
// with a remote driver) or within a process; subscribers
// hold a queue and run on their own goroutine.
// with a remote driver) or within a process; subscribers
// hold a queue and run on their own goroutine.
package broadcasting

import (
Expand Down
1 change: 1 addition & 0 deletions broadcasting/broadcasting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func TestConcurrentPublishSubscribe(t *testing.T) {
return nil
})
subs = append(subs, s)
_ = subs
}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
Expand Down
24 changes: 12 additions & 12 deletions carbon/carbon.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,18 @@ func (c Carbon) DateTime() string { return c.t.Format("2006-01-02 15:04:05") }

// --- arithmetic ---------------------------------------------------------

func (c Carbon) Add(d time.Duration) Carbon { return Carbon{t: c.t.Add(d)} }
func (c Carbon) Sub(o Carbon) time.Duration { return c.t.Sub(o.t) }
func (c Carbon) AddSeconds(n int) Carbon { return c.Add(time.Duration(n) * time.Second) }
func (c Carbon) AddMinutes(n int) Carbon { return c.Add(time.Duration(n) * time.Minute) }
func (c Carbon) AddHours(n int) Carbon { return c.Add(time.Duration(n) * time.Hour) }
func (c Carbon) AddDays(n int) Carbon { return Carbon{t: c.t.AddDate(0, 0, n)} }
func (c Carbon) AddWeeks(n int) Carbon { return Carbon{t: c.t.AddDate(0, 0, n*7)} }
func (c Carbon) AddMonths(n int) Carbon { return Carbon{t: c.t.AddDate(0, n, 0)} }
func (c Carbon) AddYears(n int) Carbon { return Carbon{t: c.t.AddDate(n, 0, 0)} }
func (c Carbon) SubDays(n int) Carbon { return c.AddDays(-n) }
func (c Carbon) SubMonths(n int) Carbon { return c.AddMonths(-n) }
func (c Carbon) SubYears(n int) Carbon { return c.AddYears(-n) }
func (c Carbon) Add(d time.Duration) Carbon { return Carbon{t: c.t.Add(d)} }
func (c Carbon) Sub(o Carbon) time.Duration { return c.t.Sub(o.t) }
func (c Carbon) AddSeconds(n int) Carbon { return c.Add(time.Duration(n) * time.Second) }
func (c Carbon) AddMinutes(n int) Carbon { return c.Add(time.Duration(n) * time.Minute) }
func (c Carbon) AddHours(n int) Carbon { return c.Add(time.Duration(n) * time.Hour) }
func (c Carbon) AddDays(n int) Carbon { return Carbon{t: c.t.AddDate(0, 0, n)} }
func (c Carbon) AddWeeks(n int) Carbon { return Carbon{t: c.t.AddDate(0, 0, n*7)} }
func (c Carbon) AddMonths(n int) Carbon { return Carbon{t: c.t.AddDate(0, n, 0)} }
func (c Carbon) AddYears(n int) Carbon { return Carbon{t: c.t.AddDate(n, 0, 0)} }
func (c Carbon) SubDays(n int) Carbon { return c.AddDays(-n) }
func (c Carbon) SubMonths(n int) Carbon { return c.AddMonths(-n) }
func (c Carbon) SubYears(n int) Carbon { return c.AddYears(-n) }

// --- boundaries ---------------------------------------------------------

Expand Down
74 changes: 74 additions & 0 deletions cli/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cli

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// BootstrapEnv is set in the environment of a re-executed project-local CLI
// so it does not bootstrap again (infinite recursion). Setting it manually
// disables the indirection.
const BootstrapEnv = "LAGO_BOOTSTRAPPED"

// projectEntrypoints are the project-local CLI mains the global binaries look
// for, in order. `lago init` / `lago new` scaffold cmd/lago.
var projectEntrypoints = []string{"cmd/lago", "cmd/artisan"}

// RunProjectBinary re-executes the project-local CLI via `go run` when the
// working directory contains one (cmd/lago/main.go or cmd/artisan/main.go).
// That binary blank-imports the project's migrations and seeders packages, so
// their init() functions populate the registries — a globally installed
// binary cannot see them and would report "nothing to migrate". It returns
// true when it handled execution; the caller must then return. On failure it
// exits with the child's status.
//
// Scaffolding commands (init, new, env*, key:generate, make:*, gen:*) never
// need the project's registries and run in-process, so they keep working in a
// fresh project whose go.sum cannot build the local entrypoint yet.
func RunProjectBinary() bool {
if os.Getenv(BootstrapEnv) == "1" || !needsProject(os.Args[1:]) {
return false
}
for _, dir := range projectEntrypoints {
if _, err := os.Stat(filepath.Join(dir, "main.go")); err != nil {
continue
}
c := exec.Command("go", append([]string{"run", "./" + dir}, os.Args[1:]...)...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Env = append(os.Environ(), BootstrapEnv+"=1")
if err := c.Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
os.Exit(ee.ExitCode())
}
fmt.Fprintln(os.Stderr, "lago: bootstrap failed:", err)
os.Exit(1)
}
return true
}
return false
}

// needsProject reports whether the command named by args may depend on the
// project's registered migrations, seeders or custom commands.
func needsProject(args []string) bool {
name := ""
for _, a := range args {
if !strings.HasPrefix(a, "-") {
name = a
break
}
}
switch {
case name == "", name == "help", name == "completion", name == "version",
name == "init", name == "new", name == "env", name == "key:generate",
strings.HasPrefix(name, "env:"), strings.HasPrefix(name, "make:"),
strings.HasPrefix(name, "gen:"):
return false
}
return true
}
41 changes: 41 additions & 0 deletions cli/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cli

import (
"strings"
"testing"
)

// RunProjectBinary must be a no-op outside a project and inside the
// re-executed child (otherwise it would recurse forever).
func TestRunProjectBinary_NoOpWithoutEntrypointOrWhenBootstrapped(t *testing.T) {
t.Chdir(t.TempDir())
if RunProjectBinary() {
t.Fatal("handled execution without a project entrypoint")
}
t.Setenv(BootstrapEnv, "1")
if RunProjectBinary() {
t.Fatal("re-executed inside an already bootstrapped child")
}
}

// Scaffolding commands run in-process; registry-dependent ones re-execute.
func TestNeedsProject(t *testing.T) {
cases := map[string]bool{
"": false,
"make:model Post -mfs": false,
"init": false,
"env:init": false,
"key:generate": false,
"gen:client": false,
"--help": false,
"migrate": true,
"migrate:fresh --seed": true,
"db:seed": true,
"my:custom": true,
}
for args, want := range cases {
if got := needsProject(strings.Fields(args)); got != want {
t.Errorf("needsProject(%q) = %v, want %v", args, got, want)
}
}
}
Loading
Loading