Skip to content

fix: bug sweep — ORM hydration/save, query builder edge cases, PG migration lock, lago CLI bootstrap, gin QueryLog - #38

Merged
devituz merged 2 commits into
mainfrom
fix/bug-sweep-2026-09
Sep 25, 2026
Merged

devituz merged 2 commits into
mainfrom
fix/bug-sweep-2026-09

Conversation

@devituz

@devituz devituz commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Bug sweep of the core (ORM, query builder, relations, migrations, CLI, web, gin adapter) plus an end-user test of v0.26.0 / @main in a fresh consumer module.

Every fix has a regression test. Each test fails on d537c37 and passes on this branch (checked by running the new tests in a worktree of d537c37).

Bugs fixed

# Location What was wrong Fix Test
1 internal/reflectutil/assign.go:16 (AssignScanned), used by orm/query.go + relations Fields implementing sql.Scanner (sql.NullString, *sql.NullString, decimal/uuid types) failed on every read: cannot assign string to sql.NullString. An INTEGER column read into a string field became a rune ("\a"). []byte numbers/bools (MySQL text protocol) failed for float/bool/uint fields. Call Scan on Scanner fields; format numbers/bools/times into strings explicitly; parse textual numbers/bools. orm/sweep_regression_test.go TestSweep_ScannerAndNumericStringFields
2 orm/query.go:283 (hydrateRows) AfterFind was declared and documented but never called. Dispatch on each hydrated element. TestSweep_AfterFindHookRuns
3 orm/query.go:318 (Save) A model with a caller-assigned, non-auto-increment PK (UUID/string) always took the UPDATE path, which matched 0 rows, so the row was never inserted. A model without a PK panicked (nil deref). For non-auto-increment keys, check whether the row exists and INSERT if it doesn't. Use plain Insert when there's no integer ID to read back. TestSweep_SaveWithAssignedStringPK
4 internal/reflectutil/cache.go:325 A *Struct / struct relation field without a relation:"…" tag (e.g. Author *User, the documented BelongsTo destination) was treated as a column, so every Save failed (no column named author). Mark struct fields as relations when they have no cast, aren't time.Time, and aren't sql.Scanner / driver.Valuer. TestSweep_PointerRelationFieldNotPersisted
5 orm/builder_ext.go:49,103 Paginate and Chunk ignored With(...), so relations stayed empty. Call eagerLoad on each page/batch. TestSweep_PaginateAndChunkEagerLoad
6 orm/query.go:125 + query/builder.go:173 The soft-delete scope and Chunk's key cursor were appended after OR-ed user conditions (a OR b AND deleted_at IS NULL). Trashed rows leaked, and Chunk looped over the same rows forever. New query.Builder.WrapWheres() puts OR-ed conditions in parentheses; scopedQB uses it. SQL without OR is unchanged. TestSweep_ScopeAppliesToWholeOrFilter, query/sweep_regression_test.go TestSweep_WrapWheres
7 orm/query.go:516 (castToDB) cast ToDB errors were swallowed and the raw Go value was written instead. Return the error from Save. TestSweep_CastErrorPropagates
8 relations/relations.go:302,343 (from the earlier session, kept) Relation loaders scanned straight into fields, so any NULL column failed. They also bucketed by raw key values, so a uint64 parent ID never matched an int64 / []byte FK and relations came back empty. Use shared scanChild (NULL-safe, honours casts) and normalizeKey. TestSweep_RelationsNullColumnsAndKeyTypes
9 query/builder.go:470 Offset() without Limit() was a syntax error on SQLite and MySQL. Emit LIMIT -1 / LIMIT 18446744073709551615. TestSweep_OffsetWithoutLimit
10 query/builder.go:893 (expand) WhereIn/WhereNotIn with any slice type outside a fixed list ([]uint, []int32, named types) bound the whole slice as one argument, which every driver rejects. Expand any slice/array via reflection. TestSweep_WhereInArbitrarySlice
11 query/builder.go:144 (nullAware) Where("col", nil) compiled to col = NULL, which never matches. Compile to IS NULL / IS NOT NULL (as Laravel does). Behaviour change: TestEdge_WhereNilValue updated. query/edge_test.go TestEdge_WhereNilValue
12 query/builder.go:505 Distinct().Select(c).Count() produced SELECT DISTINCT COUNT(*), which counts all rows. Wrap in a subquery like GROUP BY. TestSweep_DistinctCountAndAggregateOffset
13 query/builder.go:562 Sum/Avg/Min/Max on a builder with Offset returned sql.ErrNoRows. Aggregate over the whole filtered set, same as Count. same
14 query/builder.go:123 An empty nested group Where(func(q){}) rendered (), which is invalid SQL. Skip empty groups. TestSweep_EmptyNestedGroupSkipped
15 query/builder.go:421 On Postgres, WHERE placeholders restarted at $1 even after Join(..., args) had used those slots. Count join args into bindings. TestSweep_JoinArgsShiftPostgresPlaceholders
16 migrations/lock.go:27,70 The Postgres advisory lock was locked and unlocked through the pool, so possibly on two different sessions. The unlock then did nothing, the lock stayed on an idle pooled connection, and the next migrator blocked forever (the timeout was ignored). Pin a dedicated *sql.Conn for the lock's lifetime; poll pg_try_advisory_lock until the timeout. migrations/lock_pg_test.go (runs when LAGODEV_TEST_PG_DSN is set; verified against postgres:16)
17 cli/bootstrap.go:31, cli/cmd/project.go:151, cli/cmd/new.go, cmd/lago/main.go README quick start: lago migrate always printed "nothing to migrate". The global lago binary can't see a project's init() registrations. Only artisan had the re-exec bootstrap, and nothing scaffolded the local entrypoint it needs. lago init / lago new scaffold cmd/lago/main.go, plus migrations/doc.go and seeders/doc.go if missing. Shared cli.RunProjectBinary() in both binaries re-runs registry-dependent commands through it. Scaffolding commands (make:*, init, env*, ...) stay in-process, so they still work before go mod tidy. cli/cmd/init_test.go, cli/bootstrap_test.go, plus a manual end-to-end run of the README flow
18 adapters/gin/middleware.go:154,205, database/connection.go:92 X-DB-Query-Count was always 0: nothing but manual ObserveQuery fed the counter (the existing test called it by hand). The header was also set after the body was written, so it never reached the client (httptest's live header map hid this). New database.Connection.OnQuery hook. The counter is now per request, via the request context. The header is stamped when the status line is written. Instrument no longer forces LogQueries=true. adapters/gin/lagogin_test.go TestQueryLogCountsRealQueries (checks w.Result().Header)
19 web/middleware.go:131 CORSWithConfig ignored an explicit AllowedHeaders list: preflight request headers were always echoed back. Echo only for the default config; enforce explicit lists. web/security_test.go TestCORSWithConfig_EnforcesAllowedHeaders

End-user test (Part B)

Fresh module lagodev-consumer: go get github.com/devituz/lagodev@latest (v0.26.0), adapters/gin@latest (v0.11.0), and also @main. The app covers a model, 4 migrations, a seeder with a factory, CRUD, HasMany/BelongsTo/BelongsToMany eager loading, soft deletes, a transaction and Gin routes. It ran on SQLite, Postgres 16 and MySQL 8.4 (docker).

  • v0.26.0 and @main: 18 failing checks on each of SQLite, Postgres and MySQL. These are bugs 1, 3, 4, 5, 9, 10, 11, 12, 13; the rest cascade from them.
  • This branch: all checks pass on all three databases.
  • go get / module paths: installs cleanly. It switches to a go ≥1.25 toolchain automatically.
  • Tag check: adapters/gin v0.11.0 still requires lagodev v0.20.2 (MVS picks the newer core, so it works). adapters/grpc, adapters/websocket and drivers/redis have no tags, so @latest resolves to pseudo-versions.

Release note

adapters/gin now uses database.Connection.OnQuery (new in this PR). In the repo it builds through go.work, as CI does. After tagging the core, bump adapters/gin/go.mod to that version before tagging adapters/gin.

The Postgres migration lock now holds one pooled connection for the duration of a migration, so Postgres needs MaxOpenConns >= 2 (the default is unlimited).

Verification

  • go build ./... and go vet ./...: clean.
  • go test -race -count=1 ./...: 56 packages ok, including the PG lock test against a live Postgres.
  • All go.work submodules vet + race-test ok.
  • staticcheck ./...: 24 findings, all pre-existing (none introduced by this branch).

…ration lock, lago CLI bootstrap, gin QueryLog

Each fix has a regression test that fails on d537c37 and passes here.
See CHANGELOG (Unreleased) and the PR description for details.
Copilot AI lite review requested due to automatic review settings September 24, 2026 23:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

- Format 5 unformatted files (websocket, broadcasting, carbon, redis, mock)
- Remove unused imports (sync, io)
- Remove unused functions and fields
- Apply staticcheck annotations for test/fixture code
- Fix deprecated reflect.PtrTo → reflect.PointerTo
- Replace S1016 struct literal with conversion
- Simplify validation loop with append
- Fix Unicode escape sequences in test data

All changes are pre-existing main branch issues, not from PR #38 fixes.
@devituz
devituz merged commit 5a82560 into main Sep 25, 2026
1 check passed
@devituz
devituz deleted the fix/bug-sweep-2026-09 branch September 25, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants