Repository navigation
Add sectionload: a UNION ALL eager-loading strategy for psql (opt-in) - #782
Draft
sandonemaki wants to merge 6 commits into
Draft
sandonemaki wants to merge 6 commits into
sandonemaki wants to merge 6 commits into
Conversation
Add dialect/psql/sectionload, the runtime for a third eager-loading strategy alongside Preload (LEFT JOIN) and ThenLoad (one query per relation). sectionload issues a single UNION ALL query whose branches each select one relation's rows, tagged with a literal discriminator column, then stitches the results back onto the parent models in Go. This trades ThenLoad's N round trips for 1 without Preload's per-relation row multiplication. Result stitching follows ThenLoad's per-parent semantics. The package is PostgreSQL-only and depends directly on the psql dialect packages (sm, psql.Quote, psql.Arg). Contents: - compose.go: builds each relation's SELECT as a *dialect.SelectQuery and assembles the UNION ALL statement - exec.go: executes the composed SQL and dispatches rows by section - stitch.go: stitches section rows onto the models' R fields - validate.go: pre-execution validation of the node tree - columnselect.go: resolves SelectOptions.Only/Except into a ColumnPlan once per build - countpreload.go, countpreload_shim.go: carry PreloadCount columns declared on the parent query through the composed SELECT and write them back via the generated PreloadCount(name, n). The shim parses the rendered alias because the generated column type is unexported; it becomes a type assertion once orm.CountPreloadColumn (stephenafamo#763) merges - errors.go: typed errors; sectionload fails loudly instead of returning a plausible but wrong result - node.go, options.go, parts.go, run.go, hooks.go, parentexpr.go: node tree, SelectOptions, per-table TableParts, Build/All entry points, Then boundaries, and parent CTE expression Nothing references this package yet; the generator templates and the opt-in plugin follow in separate commits.
Add the test suite for dialect/psql/sectionload: unit tests with no database dependency (SQL composition, validation, error paths, column restriction, PreloadCount bridging, multi-hop count, ORDER BY handling, qualifier rewriting) and DB-backed end-to-end tests. The DB-backed tests read the connection string from the SECTIONLOAD_TEST_DSN environment variable and skip when it is unset or the database is unreachable, so they skip in CI where no PostgreSQL service is configured. testdata/baseline/*.sql pins the composed SQL for the baseline shapes (single edge, siblings, nested, then boundary, with count).
Add gen/bobgen-psql/templates/sectionload, the templates that emit the generated side of sectionload per table: - table/00_parts.go.tpl: the TableParts value and scan buffers - table/01_edges.go.tpl: edge constructors Rel(...) and RelWith(...) - table/02_counts.go.tpl: count-branch constructors, including multi-hop (through) relations - bob_sectionload.bob.go.tpl: the per-package contract version constant - sectionload_hookcheck.bob.go.tpl: runtime probe for excluded tables templates.go embeds the directory so edges_template_test.go can check the template source text directly (the to-one branch guard and the ChildKeyFromBuf/ParentKeyFromBuf output). gen/output.go only reads *.tpl files, so the two Go files in this directory do not affect generation. The templates are not wired into any generator yet.
Add a Sectionload fs.FS field to gen.Templates and populate it from templates/sectionload in buildTemplatesFromKnownDirStructure. The directory only exists under bobgen-psql/templates. For mysql and sqlite, fs.Sub still succeeds (it does not check that the directory exists) and a later fs.ReadDir fails with fs.ErrNotExist. The sectionload plugin uses that to skip registering its output for those dialects.
Add gen/plugins/sectionload.go, a gen.StatePlugin / gen.TemplateDataPlugin that registers a "dbsections" output, following the shape of the existing models/loaders/counts plugins. SectionloadConfig (bob.yaml plugins.sectionload) embeds OutputConfig and adds exclude_file / exclude for tables that must not take part in sectionload. PlugTemplateData drops the excluded tables and every relationship that touches them, and emits the hook probe list. The plugin is opt-in: unless enabled: true is set, PlugState registers nothing and existing generated output is unchanged. On mysql and sqlite hasUsableTemplates() finds no *.tpl entries and the plugin likewise registers nothing. The embedded OutputConfig uses the mapstructure `,squash` tag, as the other bob config types do; with yaml `,inline` the destination/pkgname fields are silently left empty by the koanf loader. Not yet called from plugins.Setup.
Add Sectionload to plugins.Config (yaml key "sectionload"), merge it in Config.Merge, and append the plugin to the list returned by Setup. Setup is shared by all bobgen-* generators, so no per-driver main.go changes are needed. The plugin registers nothing unless plugins.sectionload.enabled is true and the dialect ships sectionload templates, which today is bobgen-psql only.
sandonemaki
marked this pull request as draft
October 11, 2026 05:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background / Motivation
This PR is the implementation we referred to in discussion #738, where we
mentioned working on a UNION-ALL-based loading strategy and you asked
what public API surface it would need. This is the first time the actual
design and code are being shared, so this section explains it from
scratch rather than assuming familiarity with anything discussed there.
bob's PostgreSQL dialect currently offers two eager-loading strategies
for relationships:
Preload(a single query, relations joined in viaLEFT JOIN) and
ThenLoad(one additional query per relation, executedand stitched in Go). Each has a cost that grows with the shape of the
query:
Preloadmultiplies the parent row set by every joined relation'scardinality (result-set blowup for multiple to-many relations on the
same query), while
ThenLoadpays one network round trip per relation,regardless of how small each relation's result is.
This PR adds a third strategy, sectionload, developed and used in
production against a downstream PostgreSQL application. Instead of one query per relation or one
join-widened query, sectionload issues a single
UNION ALLquery whosebranches ("sections") each select one relation's rows (tagged with a
literal discriminator column), then stitches the results back onto the
parent models in Go — trading
ThenLoad's N round trips for 1, withoutPreload's row-multiplication. Its result-stitching followsThenLoad'sper-parent semantics, so the two are designed to produce equivalent
results wherever both apply (see the third discussion point under "Discussion points for maintainers"
below for where that equivalence does and doesn't hold).
sectionload is designed to be opt-in and additive: a project that does
not enable it sees no change to any existing generated output, and no
change to
Preload/ThenLoad's existing behavior or generated code.This PR is submitted separately from, but alongside, a small companion
PR that exports
orm.CountPreloadColumn(currently an unexportedgenerated type). sectionload's
PreloadCountbridge (described below)depends on identifying that type; until the companion PR merges,
sectionload works around this with an interim shim. The two PRs have no
ordering dependency and can be reviewed independently — see the
PreloadCountentry under Problems and solutions for details.Where to start reading: the runtime lives under
dialect/psql/sectionload/and the generated-code templates undergen/bobgen-psql/templates/sectionload/;gen/plugins/sectionload.gois the opt-in switch that wires the templates into
bobgen-psql(aproject only gets any of this by setting
plugins.sectionload.enabled: trueinbob.yaml). Within the runtimepackage,
compose.go(SQL assembly) andexec.go(execution + rowstitching) are the two files that carry the core logic;
run.go'sBuild/Allare the entry points a generated caller uses. For tests,multihop_e2e_test.goandmultihop_sql_test.goare a reasonablestarting pair (end-to-end execution and SQL-shape assertions
respectively) before the more targeted files that each pin one
behavior.
What's included
dialect/psql/sectionload/): the library that generatedcode calls at request time — SQL composition (
compose.go), executionand row dispatch (
exec.go), result stitching ontoRfields(
stitch.go), pre-execution validation (validate.go), columnrestriction (
columnselect.go), declared-PreloadCountbridging(
countpreload.go/countpreload_shim.go), and sectionload-specificerror types (
errors.go), plus supporting files (node.go,options.go,parts.go,run.go,hooks.go,parentexpr.go). 14 non-test files, 49test files (unit + DB-backed), 5 SQL fixture files under
testdata/.gen/bobgen-psql/templates/sectionload/): the.tpltemplates (table/00_parts.go.tpl,table/01_edges.go.tpl,table/02_counts.go.tpl,bob_sectionload.bob.go.tpl,sectionload_hookcheck.bob.go.tpl) that emit, per table, aTablePartsvalue, edge constructors (
Rel(...)/RelWith(...)), and count-branchconstructors — plus
templates.go(anembed.FSused by this package'sown template-source regression test) and
edges_template_test.go.gen/plugins/sectionload.go, new file): registersa
dbsections-equivalent output as agen.StatePlugin/gen.TemplateDataPlugin, following the same shape as the existinggen/plugins/models.go/loaders.go/counts.go. It is opt-in:unless a project's
bob.yamlsetsplugins.sectionload.enabled: true,this plugin registers nothing and generates no output. For
mysql/sqlitedrivers it detects the absent template directory(
fs.Subsucceeds butfs.ReadDirreturnsfs.ErrNotExist) viahasUsableTemplates()and likewise registers no output — this PR isPostgreSQL-only (see Scope below).
gen/templates.goandgen/plugins/plugins.go, wiring the new plugin's config(
SectionloadConfig) and templates into the existingplugins.Setupcall shared by all
bobgen-*generators.gen/bobgen-psql/main.goisunchanged — no per-driver wiring was needed.
The commits are split by layer (runtime, runtime tests, templates,
gen.Templatesfield, plugin,plugins.Setupwiring) so each can bereviewed on its own.
How to use it
sectionload is turned on in
bob.yaml, generates one package of relation definitions next to your models, and is called with a parent query plus a tree of relations. The examples below use a neutral schema:userswithposts,postswithcommentsandtags, andusers → user_roles → rolesas a multi-hop relationship.Enabling generation
Add a
sectionloadentry underpluginsinbob.yaml. Nothing is generated unlessenabled: trueis set;destinationandpkgnamework as for other outputs.Excluded tables, and relationships that touch them, are left out of the generated package.
What is generated
For each table, the package contains a
<Table>Partsvalue (for exampledbsections.UserParts), which describes the table to the runtime, and a<Table>value (for exampledbsections.User) with one set of methods per relationship:<Rel>(children...)loads the relationship, with optional nested children.<Rel>With(options, children...)does the same withsectionload.SelectOptionsapplied to the relationship's SELECT.<Rel>Count(options)loads only a per-parent count.Basic call
sectionload.Alltakes a context, an executor, the parent query, the parent table'sParts, and the relations to load. It runs oneUNION ALLquery and returns the parents in the parent query's order, with relations attached.sectionload.Buildtakes the same arguments without the executor and returns the composed SQL and arguments without running them, which is useful for inspecting the query.Writing each kind of load
Filtering and ordering a relation: pass bob SELECT mods through
sectionload.Mods.Restricting columns: use
sectionload.Onlyorsectionload.Except, or chain them ontoModsto combine them with other options. Join keys are always fetched.Nesting, including multi-hop relationships: pass child nodes to a relation. A multi-hop relationship is written like any other.
Counts only:
<Rel>CountfillsC.<Rel>without loading the rows.bob's own
PreloadCounton the parent query is also honored, so an existing count preload can be passed through unchanged:Query boundaries: wrapping a node in
sectionload.Then(...)loads that subtree with its own query, one extra round trip, exactly like an explicitThenLoad. Use it to split a very wide tree or to keep an existingThenLoadboundary.Reading the results
Results land where bob's
ThenLoadputs them: loaded models inR.<Rel>, load state inR.Loaded.<Rel>, and counts inC.<Rel>. Code that reads the results ofThenLoadreads sectionload's results the same way.Errors
Specifications that sectionload does not support are rejected with a typed error before the query runs; the cases are described under "Problems and solutions" below.
When to use sectionload (guidance for callers)
sectionload pays off when one parent query has three or more child relations and the parent row set is bounded (an id list or a page of a few dozen to a few hundred rows). Outside that shape, bob's standard loaders are as fast or faster. Based on measurements on a production PostgreSQL database:
ThenLoad; sectionload would save only 1–2 round trips while still paying for the_sl_srcCTEsm.Columnson the parent when few parent columns are readWhy the parent size matters: the parent result is materialized once as the
_sl_srcCTE with all parent columns. With tens of thousands of parent rows the CTE cost dominated and sectionload was clearly slower thanThenLoad; switching the CTE to NOT MATERIALIZED narrowed the gap but did not close it, so it was rejected. With a page of a few dozen parents the same shape stayed fast and the sort stayed a top-N heapsort.Where it wins: JOIN-style
Preloadwhose children are shared by many parents (the child row is sent once instead of once per parent, which cut both latency and bytes transferred in a proof of concept), several siblingThenLoads collapsed into one round trip, nested loads at any depth, count loads, and LIMITed parents sorted by a joined column or expression (the sort key is hoisted so the top-N heapsort survives).Where it does not: branches that contain tenancy-hooked tables stay as plain
ThenLoadoutside the island (hooks do not fire inside the composed SQL); parent column subsets (sm.Columnson the parent) are rejected by contract; child options that add their own JOINs are rejected at build time.Problems and solutions
This section lists the problems sectionload has to solve and how this PR solves each of them, starting with what a caller can see and ending with how the runtime is put together internally.
What a caller can express
Anything a caller can write with
ThenLoadshould be writable with sectionload. Each of these features leaves the SQL of calls that do not use it unchanged, and rejects with a typed error the cases it cannot support.1. WHERE / ORDER BY / LIMIT on a relation
Problem: With bob's
ThenLoad, a caller can narrow a relation by passing mods to its SELECT. Without an equivalent, a sectionload relation could only be loaded unfiltered: there would be no way to say "only the active rows", and rows the caller never meant to see could end up attached.Solution: The public
SelectOptionstype and the generated<Rel>With(options, children...)method carry such options. They are applied to that relation's SELECT only, after the join condition, the same order bob uses, and never leak to the parent or to siblings. BecauseUNION ALLdoes not preserve row order, an ORDER BY on a relation is preserved by numbering its rows inside the branch and sorting on that number when stitching. Options that would break the shape of the combined query, such as adding JOINs, are rejected withSelectOptionsStructureError.Unchanged: The plain
<Rel>(children...)method remains, and produces the same SQL as<Rel>Withwith empty options.2. Multi-hop (through) relationships
Problem: Relationships that go through a join table, such as
User → user_roles → Role, are a single relationship in bob. Nesting two single-hop loads in their place is not an acceptable substitute.Solution: A relation's join condition is an ordered list of hops rather than a single hop. For multi-hop relations only, the SELECT carries the parent's key as an extra internal column alongside each row, so the final table's rows can be stitched back to the right parent within one section. The relationship is loaded as the single relationship bob defines, all the way to the final model.
Unchanged: Single-hop relations produce the same SQL as they would without multi-hop support.
3. Count-only loads, the equivalent of
ThenLoadCountProblem: bob's
ThenLoadCountfillsC.<Rel>with a per-parent count without loading the related rows. Without an equivalent, the only option would be to load every related row just to count them, paying for the transfer and model construction. Leaving counts unset is not acceptable either, because bob distinguishes nil ("not loaded") from a pointer to 0 ("loaded, zero rows").Solution: The count-only edge type
CountEdgeadds a branch to the sameUNION ALLthat returns only the parent key and a count. Every parent receives a value, including 0 when there are no children, so the nil/0 distinction matches bob. A count node loads no models, so it must be a leaf, and applyingThen(...)to it is rejected withCountNodeThenError.4. Counts over multi-hop relationships
Problem: A count over a relationship that goes through a join table needs the same hop handling as loading its rows, and the numbers must match what a caller gets from bob.
Solution:
CountEdgesupports multi-hop relationships as well as single-hop ones. The counting semantics follow bob'sLoadCountexactly: rows are counted after the joins, duplicates included, with no DISTINCT, rather than a "nicer" count that would differ from bob. The generator supplies key-column information for these counts, and a mismatch between that information and the relationship is rejected withCountKeyColsMismatchErrorrather than risking counts attributed to the wrong parent.Unchanged: Single-hop counts produce the same SQL as they would without multi-hop support.
5. Restricting the columns read for a relation (
SelectOptions.Only/Except)Problem: In bob,
psql.PreloadOnly/psql.PreloadExceptorsm.Columns(...)on a child SELECT let a caller skip heavy columns, and a sectionload caller needs the same ability.Solution:
SelectOptions.Only(cols...)andSelectOptions.Except(cols...)feed the relation's column plan, so the SQL, the padding and the scanning all follow the selection. Join key columns are always fetched for stitching, so naming one inExceptis rejected withKeyColumnExcludedErrorinstead of being silently ignored or breaking stitching. Column selection on count edges and on multi-hop edges is not supported and is rejected with explicit errors (CountColumnRestrictionUnsupportedError,MultiHopColumnRestrictionUnsupportedError).Unchanged: Fields that are not selected are left in the same zero/null state bob leaves them in.
6. Fully qualified column references in relation options
Problem: Inside a section, the child table carries a sectionload-specific alias. An ORDER BY or WHERE containing a fully qualified reference such as
"users_groups"."user_id"would therefore fail at run time, because that table name does not appear in the section's FROM clause, even though the same option works with bob'sThenLoad.Solution: Two-part
table.columnreferences inside a section are rewritten to the section's alias before the SQL is written. A small lexer skips string literals, dollar-quoted strings and quoted identifiers, so text that merely looks like a reference is never touched.Unchanged: Three-part, schema-qualified names are left alone, and options that add JOINs remain rejected at build time.
Returning the same results as bob's own loaders
A combined query can silently diverge from what bob returns, or fail where bob succeeds. Each case below is handled so that sectionload's result matches bob's.
7. A to-one relation without a child is marked as loaded
Problem: For a to-one relation whose foreign key is NULL, or whose target row does not exist, bob sets
R.<Rel>to nil andR.Loaded.<Rel>to true: "we looked, and there was nothing". If such parents were skipped,R.Loaded.<Rel>would stay false, which means "never loaded", and any caller that checksR.Loadedwould behave differently from bob.Solution: sectionload passes a typed nil to the generated model's
Preloadfor every parent without a child, which produces exactly bob's "nil and loaded" state.Unchanged: Parents that do have a child are handled the same as always.
8. One row's scan cannot overwrite another model's jsonb data
Problem: Building a model copies the scan buffer's fields by value, but for types backed by a slice, such as jsonb data, only the slice header is copied, and a scan into the same buffer writes into the same backing array in place. If one buffer were reused for every row, reading the next row would silently rewrite the contents of the model built from the previous one.
Solution: The buffer a model was built from is replaced with a fresh one right after the model is built, so a finished model is never touched by a subsequent scan.
Unchanged: The generated templates, the public API and the composed SQL are not affected.
9. Each
Then(...)boundary has its own key slotProblem: A
Then(...)boundary nested under a non-root section needs the keys of the rows it hangs under. If it looked them up in a slot that belongs to a different section, matched by a number that only coincidentally lines up, the length check would not catch it when row counts happen to match, and children would be silently stitched to the wrong parents.Solution: Keys for each
Then(...)boundary are extracted into a dedicated slot at scan time, and that slot is kept in sync wherever rows are reordered.Unchanged: This is purely Go-side bookkeeping; the SQL is not affected.
10.
psql.UseSchemaapplies to relation tablesProblem:
psql.UseSchema(ctx, "tenant_a")lets a caller switch schemas at run time, and bob resolves table names from the context when it builds SQL. If sectionload wrote relation table names itself, the context's schema would apply to the parent but not to the relations, and a parent from the tenant's schema could get relations from another, mixing data across tenants.Solution: Relation table names are resolved by the same schema resolution bob uses, so the context's schema applies to every relation. A test against a real database with two schemas confirms that each returns only its own tenant's rows.
11. bob's
PreloadCounton the parent query is honoredProblem: bob's
PreloadCountadds a correlated subquery column to the parent SELECT and uses a MapperMod to pick up that column and call the model'sPreloadCount(name, n). The combined query does not run the parent query's MapperMods, so without special handling the count column would be computed but dropped on the way out, andC.<Rel>would silently stay nil.Solution: sectionload reads which
PreloadCounts the parent query declares, carries those columns along with the parent rows, and calls the generated model'sPreloadCount(name, n)itself. Declaring both aPreloadCountand a count-onlyCountEdgefor the same relation is rejected withPreloadCountConflictError, since the two would compete for the same field. Reading the declarations currently goes through a temporary fallback, which will be replaced by a direct type assertion onceorm.CountPreloadColumn(#763) is merged.12.
PreloadCountcolumns survive row numbering of ordered parentsProblem: When the parent query has an ORDER BY, sectionload can number the parent rows inside the parent query itself, and in that case the parent's columns are re-selected. If the declared
PreloadCountcolumns were not carried through that re-selection, the outer SELECT would refer to columns that no longer exist, and the query would fail with a "column does not exist" error.Solution: The count columns are carried explicitly through that path as well. The general rule is that wherever the parent's columns are re-selected, the declared count columns come along.
13.
?operators in relationship conditions stay intactProblem: Relationship definitions can carry fixed conditions (
from_where/to_where) whose values are SQL expressions, and these can legitimately contain PostgreSQL operators spelled with?, such as the jsonb key-existence operator. Embedding those expressions in a way that treats every?as a bind placeholder would break the argument count and make building the query fail.Solution: These expressions are embedded in a way that does not interpret
?as a placeholder, so the condition reaches PostgreSQL exactly as written.14. Column names or type names may contain
?Problem: The parent's column list is built from column names and NULL-cast type names. PostgreSQL allows
?inside a quoted identifier, so a perfectly legal column name such as"flag?"would make building the query fail if the list were embedded in a way that treats?as a placeholder.Solution: The column list uses the same placeholder-free embedding as relationship conditions, so such names pass through unchanged.
15. Parent row order follows the parent query's ORDER BY
Problem: To restore parent order after the combined query, sectionload numbers the parent rows. A numbering window with no ORDER BY would follow the parent query's own ORDER BY only if the planner happened to preserve materialization order, which SQL does not guarantee.
Solution: When the parent query has an ORDER BY, a row number ordered by that same ORDER BY is added to the parent query itself, and the parent rows are numbered by it. Parent order is therefore guaranteed by the SQL, not by planner behavior.
Inputs that are refused
For some inputs sectionload cannot produce a correct answer, and carrying on would return an incomplete or wrong graph without any signal. These inputs fail early with a typed error that names the problem.
16. Parent-query MapperMods that sectionload cannot apply are rejected
Problem: bob's
Preloadfamily works by adding a JOIN, extra SELECT columns and a MapperMod that scans those columns. The combined query does not run such MapperMods, so a parent query carrying one, such as a JOIN-stylePreload, would neither fail nor load anything: the extra data would be silently ignored, which is the worst outcome for a caller.Solution: sectionload detects MapperMods it cannot apply before the query runs, and returns
UnsupportedMapperModsErrorwith the reason. This also covers MapperMods that a contextual mod adds only while the query is being built. A parent query type whose mods cannot be inspected is refused withUncheckableParentQueryErrorrather than passed through unchecked.Unchanged:
PreloadCountdeclarations are allowed, because sectionload handles them itself.17.
Then(...)on relations with a parent-side fixed condition is rejectedProblem: A relationship can carry a fixed condition on its parent (
from) side. Within one composed query this condition is applied, but across aThen(...)boundary the parent keys are matched as a literal list, and the condition would be dropped, attaching children to parents that do not satisfy it.Solution: This combination is rejected with
ThenBoundaryFromWhereErrorbefore any SQL is composed. Refusal is preferred over a full implementation because bob itself handles parent-side conditions in three different ways depending on the path, so there is no single established behavior to reproduce.Unchanged: Conditions on the child (
to) side are applied on every path.18. Parent column subsets fail, by contract
Problem: sectionload reads every parent column from the parent query's result. If the parent query narrows its columns with
sm.Columns(...), the columns sectionload expects are missing.Solution: The contract is that such a query fails with an error: in practice the database's "column does not exist" error, not a
LayoutError, which is only raised after the result columns have been fetched and compared. Failing is the safe outcome, and the tests pin this contract. Parent column subsets are deliberately not supported.The shape of the generated SQL
The generated SQL is arranged to be cheap for PostgreSQL to run, without affecting what is returned.
19. Each UNION ALL branch is a single SELECT
Problem: Every
UNION ALLbranch has to be padded with NULLs to the full width of the combined query. Doing that with two nested SELECTs, an inner one that fetches the child table's columns and an outer one that re-reads it to pad it, adds nothing but SQL text.Solution: Each branch's final column list, including the NULL padding, is built directly in a single SELECT.
Unchanged: Returned rows, column order, column names and bind arguments are the same as with the nested form.
20. Parent rows are numbered after LIMIT when the ORDER BY uses the parent's own columns
Problem: Numbering an ordered parent's rows inside the parent query places the row-number window between the sort and the limit. With a LIMIT and an ORDER BY on unindexed columns, that prevents PostgreSQL from using a top-N heapsort, so the whole parent set is sorted.
Solution: When the ORDER BY refers only to the parent table's own columns, the row number is assigned outside the parent subquery, after the LIMIT has been applied, so the window only sees the rows that survive the LIMIT and the top-N sort stays available.
21. The fast path also covers sorting by a joined column or an expression
Problem: Numbering after LIMIT needs the sort key to be visible outside the parent subquery. A parent sorted by a joined table's column, an EXISTS expression or an expression with bind arguments does not expose its sort key there, and would fall back to a full sort.
Solution: Each sort key is lifted into the LIMITed parent subquery as a column of its own, so the outer row number can refer to it and the top-N sort is preserved. If a lifted column's name would collide with a parent column, sectionload numbers the rows inside the parent query instead, which is correct but slower.
Execution order
bob runs loaders before after-select hooks and builds a query once per execution. sectionload follows the same order, so hooks and contextual mods behave as they do with bob.
22. Parent-attached loaders run before after-select hooks
Problem: bob runs loaders first and after-select hooks second. In the opposite order, a hook could not see what a loader attached to the parent query had loaded, and a loader error could not stop the hook from running.
Solution: sectionload follows bob's order: loaders, then hooks.
23.
Then(...)boundaries run before the parent's after-select hooksProblem: A
Then(...)boundary is a loader in all but name, the equivalent of aThenLoad. If it ran after the parent's after-select hooks, the hooks would see a model graph whose relations had not been stitched yet.Solution:
Then(...)loads run before the parent's hooks, matching bob's loader-then-hook order.24. The parent query is built once, so contextual mods run once
Problem: A contextual mod runs every time a query is built. Building the parent query twice per call, for example once for a reserved-name check and once inside the combined query, would run contextual mods one more time than bob does, and a mod that merges into what is already there, such as one that adds a query hint, could produce a duplicated result.
Solution: The parent query is rendered exactly once, where it is embedded into the combined query, and the reserved-name check happens at that same point.
How the runtime is put together
The mechanisms everything above depends on. None of them alters what a caller gets back.
25. Relation SELECTs are built with bob's query builder
Problem: A
bob.Mod[*dialect.SelectQuery]can only be applied to a query object, never to a string. If relation SELECTs were assembled by concatenating strings, there would be nowhere to attach a caller's WHERE or ORDER BY, and bob's own table-name resolution could not take part.Solution: Every relation SELECT is built by a single builder that produces a bob SELECT query object. Relation options, schema resolution and the placeholder-safe embedding of SQL fragments all rely on this. The builder determines how a query is assembled, not what it fetches.
26. Each relation has a single column plan
Problem: When a relation fetches fewer than all of its columns, four things have to agree with each other: the SQL column list, the NULL padding that keeps all
UNION ALLbranches the same width, the scan targets, and the construction of the model from what was scanned. If any one of them were derived separately, they could drift apart and values would land in the wrong fields.Solution: Each relation has one column plan, and all four are derived from it, so they cannot disagree. Join key columns are always part of the plan, whatever the caller asks for, because stitching cannot work without them. Section numbering does not depend on declaration order, so a
Then(...)declared before an ordinary sibling relation leaves the siblings' numbering intact.Unchanged: When all columns are selected, the column plan produces the same SQL as it would with no column restriction at all.
27. Key normalization does not mutate the caller's slice
Problem: Before key values are used as map keys,
[]byteelements have to be converted tostring, because[]byteis not comparable. If that conversion rewrote the slice it was given, in place, any caller that reuses a slice would see its values change underneath it, which is the same kind of silent corruption as a shared scan buffer.Solution: The normalization helper leaves its argument untouched and puts the normalized values only into its return value.
Unchanged: The keys it returns are the same either way; only the side effect is absent.
28. Generation is a
bobgen-psqlpluginProblem: sectionload's generated code has to be produced by bob's own generator, without affecting projects that do not use it.
Solution: Code generation is an additional output of
bobgen-psql, enabled throughplugins.sectionloadinbob.yaml, rather than a separate command. It is off by default and generates nothing unlessenabled: trueis set explicitly. For MySQL and SQLite the plugin does not register an output at all, since sectionload is PostgreSQL-only.Unchanged: The plugin plugs into the shared plugin setup, so the per-dialect
main.gofiles are untouched.Related changes in bob itself
Some of the behavior sectionload relies on belongs in bob rather than in sectionload. Those changes were submitted separately and are already merged as #755, #756, #757, #758 and #759, so this PR does not repeat them. Of these, only #757 affects sectionload's results: it makes the generated
Preloadappend the parent to a to-many back-reference instead of overwriting it. sectionload delegates stitching to the generatedPreload, so with #757 in place, a child shared by several parents keeps all of them in its back-reference, just as with bob's own loaders.Scope declarations
dialect/psql/sectionload/and
gen/bobgen-psql/templates/sectionload/, and depends directly onpsql-dialect packages (
sm,psql.Quote,psql.Arg, etc.). Unlikeloaders/counts, which are mirrored acrossgen/bobgen-mysqlandgen/bobgen-sqlite, this PR does not adddialect/mysql/sectionload/or
dialect/sqlite/sectionload/equivalents — the SQL-dialectdifferences involved (semi-join construction, placeholder syntax,
identifier quoting) are unverified for those dialects, and all
production usage and testing to date is PostgreSQL-only. Multi-dialect
support is left as future work.
CountEdgeis supported (added after single-hop-onlysupport in an earlier iteration; see Testing below).
child instance is shared across multiple parents that point to it)
matches bob's
Preloadbehavior by construction (both rely on the samesemi-join structure), verified by pointer-identity tests. Multi-hop
relations keep
ThenLoad's per-parent-instance semantics rather thanbob's
Preloadsemantics — see the third discussion point under "Discussion points for maintainers" below for why.Testing
dialect/psql/sectionload/carries 49 test files (unit tests with noDB dependency, plus DB-backed tests run against a real PostgreSQL
instance), covering SQL composition, validation, error paths, column
restriction,
PreloadCountbridging, and multi-hop count.SECTIONLOAD_TEST_DSNenvironment variable and skip when it is unsetor the database is unreachable, so in this repository's CI (which
starts no PostgreSQL service) they are skipped rather than run. They
do not use testcontainers like bob's existing
dialect/psqltests;switching them over is a reasonable ask if you'd prefer that.
submitted): with
SECTIONLOAD_TEST_DSNpointing at a PostgreSQL 16instance,
go test -count=1 ./dialect/psql/sectionload/...reports256 tests passing, 0 failing, 0 skipped, so every DB-backed test ran
against a live database.
golangci-lint runwith this repository's.golangci.ymlreports 0 issues.golden tests; single-hop
CountEdgeSQL output is confirmed unchangedby the addition of multi-hop support.
originally built for: its full generated-package
test suite (the generated output of this same templates/runtime pair)
passes against real schema and data. That verification predates the
path/package rename needed to land this inside bob itself, and has not
been re-run against the in-tree paths used in this PR.
bobgen-psqlend-to-end withplugins.sectionload.enabled: trueset in abob.yaml, to confirm thegenerator plugin produces a working
dbsections-equivalent packagefrom scratch. The plugin itself was exercised at the
gen.StatePluginunit level (
PlugState), but full generator-to-output verificationneeds a project scaffold (its own
go.mod, live schema) that wasn'tset up for this submission.
Discussion points for maintainers
Package placement. Existing
dialect/psql/packages follow ashort-name convention for query-clause mods (
fm,sm,um,im).sectionloadis both a longer name and a different kind of thing — anexecution-time library, not a clause builder — and placing it at
dialect/psql/sectionload/is this submission's own guess rather thana settled convention. Alternatives (e.g. a top-level
extras/sectionload/package) may fit better; input welcome.
A future API to let callers verify a
MapperMod's provenance.The
PreloadCountbridge (countpreload.go) currently accepts aquery's
bob.MapperModslice as count-preload-related on the basisthat its length matches the number of
CountPreloadColumn-shapeddeclarations read off the same query's
PreloadColumns— acount-based proxy, not a proof. A caller who deliberately replaces the
MapperModslice viaorm.Load.SetMapperMods()with unrelatedMapperMods of the same count would not be caught by this check. We'dlike to propose (in a follow-up, not part of this PR) giving each
MapperModpublic metadata — kind, target relationship name, and anopaque identifier shared with its originating declaration — so a
caller could require both a kind match and an identifier match before
trusting a
MapperMod. Happy to discuss this separately oncemaintainers have had a chance to look at the base PR.
A
Preload/ThenLoadsemantic inconsistency for multi-hoprelations, unrelated to this PR's own code but surfaced while building
it. For a single-hop relation,
Preload's JOIN-based rowdeduplication and
ThenLoad's per-parent stitching produce the sameresult (both rely on the same semi-join structure). For a multi-hop
(through) relation, they diverge:
Preload's dedup key is the finaltable's own columns, so it collapses to one shared child instance,
while
ThenLoad's slice loader(
gen/templates/loaders/table/110_loaders.go.tpl) keys by theparent's key, producing a separate instance per parent. This means
orm/load.go's doc comment describingPreloadas having "the samesemantics as ThenLoad" holds only for single-hop relations. sectionload
follows
ThenLoad's structure and soinherits this divergence from
Preloadfor multi-hop relations ratherthan resolving it; we're reporting it here as an existing inconsistency
for maintainers' awareness, not proposing a fix as part of this PR.
Honest disclosures
the downstream application and then adapted to bob's
bobgen-psql-integratedplugin model for submission; the generator-plugin file
(
gen/plugins/sectionload.go) is a from-scratch rewrite for thatintegration, not a direct port, and (as noted above) has not yet been
exercised through a full end-to-end
bobgen-psqlgeneration run.parts.go,options.go,node.go,run.go,errors.go) accumulated changes across several internaldevelopment iterations before being packaged for submission; only the
final state of each file is available, not a per-iteration diff. If a
line-by-line breakdown of "which change came from where" is useful for
review, ask and we'll dig up what we can from internal history.