refactor(psql): simplify immutable query internals - #657
Conversation
|
One migration decision worth calling out separately: we now have two viable paths for client-facing semantics.
My bias is that if we are willing to take a breaking change anyway, removing |
|
@stephenafamo Here is the PR migrating everything to be immutable by design |
There was a problem hiding this comment.
Pull request overview
This PR continues the “immutable-by-default” refactor for Bob’s query types, simplifying psql-specific wrappers/derivation while preserving the specialized psql writer path and benchmark performance.
Changes:
- Make
bob.BaseQuery(and ORM query wrappers) immutable-by-default viaWith()/Apply()returning derived copies. - Introduce simplified psql query wrappers (
SelectQuery/InsertQuery/DeleteQuery/UpdateQuery) and dialect-level derivation/cloning helpers. - Update psql/mysql/sqlite table/view entrypoints and add regression + immutability tests/benchmarks.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| query.go | BaseQuery cloning now preserves QueryType; adds immutable With() and makes Apply() return a derived copy. |
| query_immutable_test.go | Adds core immutability tests for BaseQuery. |
| orm/query.go | Adds immutable With()/Apply() for ORM ExecQuery and Query and fixes cloning to preserve Scanner. |
| mods/mods.go | Adds shared mods (TargetOnly, TargetTable, Distinct, etc.) to reduce dialect glue. |
| dialect/psql/select.go / insert.go / delete.go / update.go | Adds lightweight psql wrapper query types with With()/Apply() (and derivation where supported). |
| dialect/psql/dialect/{select,insert,update,delete}.go | Refactors SQL writing to use new internal queryWriter and adds setter methods used by shared mods. |
| dialect/psql/dialect/{derive,clone}.go (+ tests) | Adds derivation fast-paths and deep-clone helpers to support immutability safely. |
| dialect/psql/view.go (+ tests) | Refactors view query wrapper to embed SelectQuery, updates hooks plumbing, and adds Count/scan path. |
| dialect/psql/*_test.go (multiple) | Adds regression/immutability coverage for With/Apply behavior and default RETURNING behavior. |
| dialect/mysql/{table,view}.go & dialect/sqlite/{table,view}.go | Updates table/view starters to respect immutable Apply/With semantics; adds MySQL insert query With()/Apply(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
Benchmarks:\n- BenchmarkUpdateQueryApplyMain: 3038 ns/op, 2319 B/op, 47 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 1974 ns/op, 1360 B/op, 41 allocs/op\n- BenchmarkDeleteQueryApplyMain: 1926 ns/op, 1956 B/op, 33 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1345 ns/op, 1027 B/op, 26 allocs/op\n- BenchmarkInsertQueryApplyMain: 1473 ns/op, 1576 B/op, 24 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1143 ns/op, 920 B/op, 20 allocs/op
Apply now matches With for psql select/view/insert/update/delete wrappers, so derivation no longer mutates the source query. Added regression coverage for immutable Apply semantics and preserved fallback SQL behavior for combined/distinct select shapes. Benchmarks versus stored upstream/main baselines: - BaseQuery immutable: 2404 ns/op vs 3039 ns/op upstream Apply - View count+paginate immutable: 5906 ns/op vs 10070 ns/op upstream Apply - Update immutable: 2147 ns/op vs 3038 ns/op upstream Apply - Delete immutable: 1587 ns/op vs 1926 ns/op upstream Apply - Insert immutable: 1295 ns/op vs 1473 ns/op upstream Apply
Remove the wrapper-level cached mutable BaseQuery compatibility form from psql select/update/delete/insert wrappers. Verification: - go test ./dialect/psql - go test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmem Benchmark snapshot after cleanup: - BaseQuery immutable: 2357 ns/op, 1705 B/op, 36 allocs/op - View count+paginate immutable: 5811 ns/op, 4731 B/op, 60 allocs/op - Update immutable: 2111 ns/op, 1360 B/op, 41 allocs/op - Delete immutable: 1555 ns/op, 1027 B/op, 26 allocs/op - Insert immutable: 1253 ns/op, 920 B/op, 20 allocs/op
Remove the unused defaultSelect field from ViewQuery, keep only the one bit of state that matters for overriding the auto-generated select list, and name the mutable writer fallback path explicitly. Verification: - go test ./dialect/psql - go test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmem Benchmark snapshot after cleanup: - BaseQuery immutable: 2410 ns/op, 1704 B/op, 36 allocs/op - View count+paginate immutable: 5655 ns/op, 4538 B/op, 60 allocs/op - Update immutable: 2092 ns/op, 1360 B/op, 41 allocs/op - Delete immutable: 1558 ns/op, 1027 B/op, 26 allocs/op - Insert immutable: 1283 ns/op, 920 B/op, 20 allocs/op
Move default view-column handling into immutable select state and route ViewQuery execution directly through bob.One/All/Cursor/Each instead of wrapper-local baseQuery/mutable helpers. Added a regression test to ensure an explicit view Select mod replaces the generated default columns. Verification: - go test ./dialect/psql ./orm ./mods ./clause ./dialect/mysql ./dialect/sqlite - go test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmem Benchmark snapshot after cleanup: - BaseQuery immutable: 2468 ns/op, 1705 B/op, 36 allocs/op - View count+paginate immutable: 5982 ns/op, 4674 B/op, 57 allocs/op - Update immutable: 2209 ns/op, 1360 B/op, 41 allocs/op - Delete immutable: 1606 ns/op, 1027 B/op, 26 allocs/op - Insert immutable: 1253 ns/op, 920 B/op, 20 allocs/op
Remove the public wrapper-level baseQuery compatibility methods from psql select/update/delete/insert types and update internal callers to use the immutable internals directly. Verification: - go test ./dialect/psql ./orm ./mods ./clause ./dialect/mysql ./dialect/sqlite - go test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmem Benchmark snapshot after cleanup: - BaseQuery immutable: 2369 ns/op, 1705 B/op, 36 allocs/op - View count+paginate immutable: 5711 ns/op, 4674 B/op, 57 allocs/op - Update immutable: 2097 ns/op, 1360 B/op, 41 allocs/op - Delete immutable: 1568 ns/op, 1027 B/op, 26 allocs/op - Insert immutable: 1291 ns/op, 920 B/op, 20 allocs/op
Replace the psql table query contextual mods that injected default RETURNING columns with constructor-time defaults, while preserving explicit RETURNING overrides. Added regression coverage for update/insert/delete table queries to assert both the default RETURNING-all-columns behavior and explicit RETURNING override behavior. Verification: - go test ./dialect/psql - go test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmem Benchmark snapshot after cleanup: - BaseQuery immutable: 2329 ns/op, 1705 B/op, 36 allocs/op - View count+paginate immutable: 5759 ns/op, 4674 B/op, 57 allocs/op - Update immutable: 2138 ns/op, 1360 B/op, 41 allocs/op - Delete immutable: 1577 ns/op, 1027 B/op, 26 allocs/op - Insert immutable: 1214 ns/op, 920 B/op, 20 allocs/op
Benchmarks:\n- BenchmarkBaseQueryImmutableNativeHotPath: 2938 ns/op, 3149 B/op, 35 allocs/op\n- BenchmarkViewQueryCountThenPaginateImmutableNativeHotPath: 6092 ns/op, 6316 B/op, 62 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 2561 ns/op, 2543 B/op, 41 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1979 ns/op, 2156 B/op, 27 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1532 ns/op, 1720 B/op, 18 allocs/op
Benchmarks:\n- BenchmarkBaseQueryImmutableNativeHotPath: 2868 ns/op, 3149 B/op, 35 allocs/op\n- BenchmarkViewQueryCountThenPaginateImmutableNativeHotPath: 5948 ns/op, 6316 B/op, 62 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 2509 ns/op, 2543 B/op, 41 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1957 ns/op, 2156 B/op, 27 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1487 ns/op, 1720 B/op, 18 allocs/op
Benchmarks:\n- BenchmarkBaseQueryImmutableNativeHotPath: 2857 ns/op, 3149 B/op, 35 allocs/op\n- BenchmarkViewQueryCountThenPaginateImmutableNativeHotPath: 5767 ns/op, 6316 B/op, 62 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 2532 ns/op, 2576 B/op, 43 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1872 ns/op, 2156 B/op, 27 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1422 ns/op, 1720 B/op, 18 allocs/op
Benchmarks:\n- BenchmarkBaseQueryImmutableNativeHotPath: 2726 ns/op, 3149 B/op, 35 allocs/op\n- BenchmarkViewQueryCountThenPaginateImmutableNativeHotPath: 5769 ns/op, 6315 B/op, 62 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 2477 ns/op, 2576 B/op, 43 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1856 ns/op, 2156 B/op, 27 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1375 ns/op, 1720 B/op, 18 allocs/op
Benchmarks:\n- BenchmarkBaseQueryImmutableNativeHotPath: 2931 ns/op, 3149 B/op, 35 allocs/op\n- BenchmarkViewQueryCountThenPaginateImmutableNativeHotPath: 5941 ns/op, 6316 B/op, 62 allocs/op\n- BenchmarkUpdateQueryImmutableNativeHotPath: 2601 ns/op, 2575 B/op, 43 allocs/op\n- BenchmarkDeleteQueryImmutableNativeHotPath: 1962 ns/op, 2156 B/op, 27 allocs/op\n- BenchmarkInsertQueryImmutableNativeHotPath: 1507 ns/op, 1720 B/op, 18 allocs/op
434568a to
d6e79df
Compare
|
@stephenafamo what will it take to get this merged? |
|
Sorry I ignored this for a while... I'm just conflicted because of how big of a breaking change this is. I'm considering having a side-by-side implementation, i.e. |
Summary
Relative to
upstream/main, this PR moves Bob further toward immutable-by-default query building and rewrites the hotpsqlquery path so that immutable query derivation stays competitive with or ahead of the current mutableApplypath.What this PR changes:
bob.BaseQueryimmutable-by-default:Apply(...)now returns a derived query instead of mutating in placeorm.ExecQueryandorm.Queryto follow the same immutable semanticsApply(...)psqlto use a single query representation instead of a parallel immutable wrapper modelClone()support and immutable derivation support for thepsqldialect query structspsqlwriter path to keep immutable derivation fast enough to beat the current upstream baselines on the main benchmark shapespsqlwrapper and table/view logic after the core rewriteClient migration
The main breaking change is semantic:
Apply(...)no longer mutates queries in placeApply(...)now behaves likeWith(...): it returns a derived querySo code like this on
upstream/main:must become:
The same applies to pointer-returning wrappers such as
orm.Query,orm.ExecQuery, table queries, and view queries:If the existing code was already written as:
q = q.Apply(...), orq2 := q.With(...)then no behavior change is required.
Practical migration notes by layer:
bob.BaseQuery: always capture the return value ofApply(...)orm.ExecQuery/orm.Query: always capture the return value ofApply(...)psql.SelectQuery,psql.UpdateQuery,psql.DeleteQuery,psql.InsertQuery: always capture the return value ofApply(...)psql,mysql, andsqlite: same rule, always capture the return value ofApply(...)What does not change:
With(...)still returns a derived querypsql.Select(...),psql.Update(...),psql.Delete(...),psql.Insert(...),view.Query(...), and table helpers still existMain areas changed
Core immutable semantics:
query.goorm/query.goquery_immutable_test.gopsqlquery core and writers:dialect/psql/dialect/clone.godialect/psql/dialect/derive.godialect/psql/dialect/writer.godialect/psql/dialect/select.godialect/psql/dialect/update.godialect/psql/dialect/delete.godialect/psql/dialect/insert.gopsqlwrappers and table/view behavior:dialect/psql/select.godialect/psql/update.godialect/psql/delete.godialect/psql/insert.godialect/psql/view.godialect/psql/table.godialect/psql/{sm,um,dm,im}/qm.goCompatibility fallout for other dialect wrappers:
dialect/mysql/{view,table}.godialect/sqlite/{view,table}.goVerification
Ran:
go test ./dialect/psql ./dialect/psql/dialect ./orm ./dialect/mysql ./dialect/sqlitego test ./dialect/psql -run '^$' -bench '^Benchmark(BaseQuery|ViewQueryCountThenPaginate|UpdateQuery|DeleteQuery|InsertQuery)(ApplyMain|ImmutableNativeHotPath)$' -benchmemLatest immutable benchmark snapshot on this branch:
BenchmarkBaseQueryImmutableNativeHotPath:2931 ns/op,3149 B/op,35 allocs/opBenchmarkViewQueryCountThenPaginateImmutableNativeHotPath:5941 ns/op,6316 B/op,62 allocs/opBenchmarkUpdateQueryImmutableNativeHotPath:2601 ns/op,2575 B/op,43 allocs/opBenchmarkDeleteQueryImmutableNativeHotPath:1962 ns/op,2156 B/op,27 allocs/opBenchmarkInsertQueryImmutableNativeHotPath:1507 ns/op,1720 B/op,18 allocs/opUpstream/main baselines used for comparison:
BenchmarkBaseQueryApplyMain:3039 ns/op,2865 B/op,48 allocs/opBenchmarkViewQueryCountThenPaginateApplyMain:10070 ns/op,7424 B/op,130 allocs/opBenchmarkUpdateQueryApplyMain:3038 ns/op,2319 B/op,47 allocs/opBenchmarkDeleteQueryApplyMain:1926 ns/op,1956 B/op,33 allocs/opBenchmarkInsertQueryApplyMain:1473 ns/op,1576 B/op,24 allocs/opNotes
This PR still adds complexity in the
psqlspecialization layer, mainly in:dialect/psql/dialect/writer.godialect/psql/dialect/derive.goThat is the current cost of keeping the immutable path at or above the upstream performance bar on the benchmarked query shapes.