feat(builder): InsertDefaults for INSERT ... DEFAULT VALUES#52
Conversation
Closes #51. Follow-up to #50. After #50, InsertRecord on an all-zero record errored out and pointed callers at sq.Expr as the escape hatch — bypassing the type-safe builder. The legitimate use cases (sequence tables, audit rows, any all-DB-defaulted insert) deserve a first-class path. DefaultValuesBuilder is a separate small type rather than overrides on pgkit.InsertBuilder. Earlier drafts tried to embed sq.InsertBuilder and shadow ToSql + Suffix, but that pretends to support 22 squirrel chain methods while only handling 2 — every other inherited method silently strips pgkit state. The new type exposes exactly what it supports: ToSql, Suffix(string), Err. DB.SQL.InsertDefaults("orders").Suffix(`RETURNING "id"`) // INSERT INTO orders DEFAULT VALUES RETURNING "id" Empty table is a build-time error via Err (raw, no pgkit: prefix — Querier.wrapErr adds it at use time). Suffix takes literal SQL only; placeholder-bearing suffixes (ON CONFLICT ... SET col = ?) fall back to sq.Expr at the call site. InsertRecord's empty-cols error now hints at SQL.InsertDefaults rather than sq.Expr; tableName empty case prints a generic placeholder. Tests: - 6 unit tests covering plain SQL, RETURNING, chained Suffix, EXCLUDED upsert, empty-table rejection, and the InsertRecord hint update - new default_only PG table in test schema + integration round-trip test exec'ing DEFAULT VALUES RETURNING id Planned via three Codex review iterations (auto-detect rejected, embed-and-shadow rejected, separate-type green-lit on v4). Plan at tmp/insert-default-values/2026-06-02-plan.md. Pre-existing pgkit.InsertBuilder state-loss bugs surfaced during review (Suffix drops err; table.go:93 chain returns sq.InsertBuilder mid-chain) are out of scope here and will land as a follow-up issue.
Code ReviewOne confirmed correctness bug, three low-severity design notes. 🔴 #1 — Unquoted table identifier (
|
Three of David's four findings on #52: - Value receiver on InsertDefaults to match the majority of sibling methods (UpdateRecord, UpdateRecordColumns, InsertRecords). InsertRecord stays *StatementBuilder for now; widening the inconsistency isn't justified. - Defensive table check in DefaultValuesBuilder.ToSql so a direct zero-value construction (var b pgkit.DefaultValuesBuilder) errors cleanly instead of emitting invalid SQL at exec time. - Suffix("") no-op so conditional-suffix callers don't get a trailing space that breaks SQL-string snapshot tests. Push back on the fourth finding (table identifier quoting) — the bug exists pgkit-wide (squirrel doesn't quote either, see Insert / Into across the repo). Fixing only InsertDefaults would create asymmetric API behavior. Will track as a separate pgkit-wide issue.
|
Pushed #1 — Table identifier quoting: Pushing back. The premise that "every other builder delegates to squirrel's #2 — Pointer receiver: Accepted. #3 — Zero-value bypass: Accepted. #4 — |
Closes #53. Follow-up to #50. with a build-time "record N columns differ from record 0" error. That was the conservative answer: it stopped squirrel from emitting malformed multi-row SQL where row widths didn't match. But it's restrictive. Realistic ,omitzero (#50) and legacy ,omitempty on map fields produce heterogeneous batches constantly. Forcing callers to split into per-row InsertRecord calls kills the point of batching. Replace the drift check with union-by-name: walk rows once to compute the column union and a per-row map[string]any of present columns, then emit Columns(allCols...) with each row padded to the union via sq.Expr("DEFAULT") in any slot the row skipped. PostgreSQL accepts DEFAULT in any VALUES position, so the resulting SQL is valid for every shape the union covers. The per-row "empty record" rejection from #50 also goes away — a row with no own columns can be all-DEFAULT *precisely because* another row contributes the union. Only the whole-batch empty case still errors, with a hint pointing at sq.Expr (matches the existing #50 hint shape on the InsertRecord single-row path). Tests: rewrote the drift-rejection tests to assert union+DEFAULT SQL including args; added empty-row-mixed-with-non-empty, heterogeneous map records, and a real PG round-trip against a new mixed_shape table that proves each row lands with the right mix of caller values, DB defaults, and NULLs. Planned via three Codex review rounds (per-row vs whole-batch reject flipped, map-padding by name vs positional, test plan gaps, sq.Expr hint vs unmerged #52 reference). Plan at tmp/insertrecords-mixed-shape/2026-06-02-plan.md.
After #50,
InsertRecordrejects records whoseMapreturns zero columns and points callers atsq.Expras the escape hatch — bypassing the type-safe builder. Legitimate use cases (sequence tables, audit rows, any all-DB-defaulted insert) deserve a first-class path.Closes #51.
Design
DefaultValuesBuilderis a separate small type, not overrides onpgkit.InsertBuilder. The plan went through three Codex review rounds before settling here:pgkit.InsertBuilderembedssq.InsertBuilder(22 chain methods). OverridingToSql+Suffixonly handles 2; the other 20 inherited methods silently strip pgkit state. Dishonest surface area.ToSql,Suffix(string),Err. Codex green-light on v4.Full plan + decision log:
tmp/insert-default-values/2026-06-02-plan.md(ignored, branch-only).Behaviour
InsertDefaults("t").ToSql()INSERT INTO t DEFAULT VALUESInsertDefaults("t").Suffix(\RETURNING "id"`)`INSERT INTO t DEFAULT VALUES RETURNING "id".Suffix(...)InsertDefaults("")Err()InsertRecord(allDefault)(existing #50 path)SQL.InsertDefaults("t")instead ofsq.ExprSuffixtakes literal SQL only. Placeholder-bearing suffixes (ON CONFLICT ... SET col = ?) fall back tosq.Exprat the call site — placeholder rebinding withoutsq.StatementBuilderType'sPlaceholderFormatgetter is non-trivial and defer-able until concrete demand surfaces.Error from
InsertDefaults("")is raw (nopgkit:prefix).Querier.wrapErratquerier.go:23,47,71adds the prefix at use time — double-wrapping would surfacepgkit: pgkit: ....Test plan
make db-reset test-allagainst PostgreSQL 18.3 — all 6 packages green.RETURNING, chained Suffix, EXCLUDED upsert, empty-table rejection,InsertRecordhint update.TestInsertDefaultsRoundTrip: execINSERT INTO default_only DEFAULT VALUES RETURNING idagainst the newdefault_onlytable (every column DB-defaulted, isolates the contract).go vet ./...clean.Out of scope (tracked separately)
Codex's review surfaced two latent state-loss bugs in
pgkit.InsertBuilder's embed-and-inherit pattern that pre-date this PR:pgkit.InsertBuilder.Suffixdropserr— inherited fromsq.InsertBuilder, returns the squirrel type, loses the pgkit wrapper.table.go:93chainInsertRecord(r).Into(t).Suffix("...")loses pgkit state via.Into(...).Both are symptoms of the same disease — embedding-and-inheriting chain methods that return the parent type. The fix is to wrap every chain method on pgkit's side. Out of scope here; will land as a follow-up issue titled "pgkit.InsertBuilder loses state through inherited squirrel methods."