Skip to content

Add sectionload: a UNION ALL eager-loading strategy for psql (opt-in) - #781

Closed
sandonemaki wants to merge 6 commits into
stephenafamo:mainfrom
sandonemaki:sectionload
Closed

sandonemaki wants to merge 6 commits into
stephenafamo:mainfrom
sandonemaki:sectionload

Conversation

@sandonemaki

@sandonemaki sandonemaki commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Jump to: How to use it · When to use sectionload · Problems and solutions · Discussion points for maintainers

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 via
LEFT JOIN) and ThenLoad (one additional query per relation, executed
and stitched in Go). Each has a cost that grows with the shape of the
query: Preload multiplies the parent row set by every joined relation's
cardinality (result-set blowup for multiple to-many relations on the
same query), while ThenLoad pays 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 ALL query whose
branches ("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, without
Preload's row-multiplication. Its result-stitching follows ThenLoad's
per-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 unexported
generated type). sectionload's PreloadCount bridge (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
PreloadCount entry under Problems and solutions for details.

Where to start reading: the runtime lives under
dialect/psql/sectionload/ and the generated-code templates under
gen/bobgen-psql/templates/sectionload/; gen/plugins/sectionload.go
is the opt-in switch that wires the templates into bobgen-psql (a
project only gets any of this by setting
plugins.sectionload.enabled: true in bob.yaml). Within the runtime
package, compose.go (SQL assembly) and exec.go (execution + row
stitching) are the two files that carry the core logic; run.go's
Build/All are the entry points a generated caller uses. For tests,
p05_e2e_test.go and p05_sql_test.go are a reasonable starting pair
(end-to-end execution and SQL-shape assertions respectively) before the
more targeted p*/r24_* files that pin individual fixes.

What's included

  • Runtime (dialect/psql/sectionload/): the library that generated
    code calls at request time — SQL composition (compose.go), execution
    and row dispatch (exec.go), result stitching onto R fields
    (stitch.go), pre-execution validation (validate.go), column
    restriction (columnselect.go), declared-PreloadCount bridging
    (countpreload.go / countpreload_shim.go), and sectionload-specific
    error types (errors.go), plus supporting files (node.go, options.go,
    parts.go, run.go, hooks.go, parentexpr.go). 14 non-test files, 49
    test files (unit + DB-backed), 5 SQL fixture files under testdata/.
  • Generated templates (gen/bobgen-psql/templates/sectionload/): the
    .tpl templates (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, a TableParts
    value, edge constructors (Rel(...) / RelWith(...)), and count-branch
    constructors — plus templates.go (an embed.FS used by this package's
    own template-source regression test) and edges_template_test.go.
  • Generator plugin (gen/plugins/sectionload.go, new file): registers
    a dbsections-equivalent output as a gen.StatePlugin /
    gen.TemplateDataPlugin, following the same shape as the existing
    gen/plugins/models.go / loaders.go / counts.go. It is opt-in:
    unless a project's bob.yaml sets plugins.sectionload.enabled: true,
    this plugin registers nothing and generates no output. For mysql/
    sqlite drivers it detects the absent template directory
    (fs.Sub succeeds but fs.ReadDir returns fs.ErrNotExist) via
    hasUsableTemplates() and likewise registers no output — this PR is
    PostgreSQL-only (see Scope below).
  • Two small existing-file changes: gen/templates.go and
    gen/plugins/plugins.go, wiring the new plugin's config
    (SectionloadConfig) and templates into the existing plugins.Setup
    call shared by all bobgen-* generators. gen/bobgen-psql/main.go is
    unchanged — no per-driver wiring was needed.

The commits are split by layer (runtime, runtime tests, templates,
gen.Templates field, plugin, plugins.Setup wiring) so each can be
reviewed 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: users with posts, posts with comments and tags, and users → user_roles → roles as a multi-hop relationship.

Enabling generation

Add a sectionload entry under plugins in bob.yaml. Nothing is generated unless enabled: true is set; destination and pkgname work as for other outputs.

plugins:
  sectionload:
    enabled: true
    destination: "dbsections"
    pkgname: "dbsections"
    exclude: "audit_logs"            # comma-separated tables to leave out
    # exclude_file: "sectionload-exclude.txt"   # or one table per line

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>Parts value (for example dbsections.UserParts), which describes the table to the runtime, and a <Table> value (for example dbsections.User) with one set of methods per relationship:

  • <Rel>(children...) loads the relationship, with optional nested children.
  • <Rel>With(options, children...) does the same with sectionload.SelectOptions applied to the relationship's SELECT.
  • <Rel>Count(options) loads only a per-parent count.

Basic call

sectionload.All takes a context, an executor, the parent query, the parent table's Parts, and the relations to load. It runs one UNION ALL query and returns the parents in the parent query's order, with relations attached.

users, err := sectionload.All(ctx, db,
    models.Users.Query(sm.Where(models.Users.Columns.OrgID.EQ(psql.Arg(orgID)))),
    dbsections.UserParts,
    dbsections.User.Posts(),
    dbsections.User.Profile(),
)

sectionload.Build takes 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.

dbsections.User.PostsWith(sectionload.Mods(
    sm.Where(models.Posts.Columns.Published.EQ(psql.Arg(true))),
    sm.OrderBy(models.Posts.Columns.CreatedAt).Desc(),
))

Restricting columns: use sectionload.Only or sectionload.Except, or chain them onto Mods to combine them with other options. Join keys are always fetched.

dbsections.User.PostsWith(sectionload.Except("body"))
dbsections.User.PostsWith(sectionload.Mods(sm.Limit(10)).Except("body"))

Nesting, including multi-hop relationships: pass child nodes to a relation. A multi-hop relationship is written like any other.

dbsections.User.Posts(dbsections.Post.Comments(), dbsections.Post.Tags())
dbsections.User.Roles() // users → user_roles → roles

Counts only: <Rel>Count fills C.<Rel> without loading the rows.

dbsections.User.PostsCount(sectionload.SelectOptions{})

bob's own PreloadCount on the parent query is also honored, so an existing count preload can be passed through unchanged:

models.Users.Query(models.PreloadCount.User.Posts())

Query boundaries: wrapping a node in sectionload.Then(...) loads that subtree with its own query, one extra round trip, exactly like an explicit ThenLoad. Use it to split a very wide tree or to keep an existing ThenLoad boundary.

dbsections.User.Posts(sectionload.Then(dbsections.Post.Tags()))

Reading the results

Results land where bob's ThenLoad puts them: loaded models in R.<Rel>, load state in R.Loaded.<Rel>, and counts in C.<Rel>. Code that reads the results of ThenLoad reads 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:

Children Parent rows Use
3 or more id list / one page (tens to hundreds of rows) sectionload
1–2 any standard ThenLoad; sectionload would save only 1–2 round trips while still paying for the _sl_src CTE
any whole group / whole server with no LIMIT, or an id list capped in the thousands standard loaders, with sm.Columns on the parent when few parent columns are read

Why the parent size matters: the parent result is materialized once as the _sl_src CTE with all parent columns. With tens of thousands of parent rows the CTE cost dominated and sectionload was clearly slower than ThenLoad; 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 Preload whose 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 sibling ThenLoads 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 ThenLoad outside the island (hooks do not fire inside the composed SQL); parent column subsets (sm.Columns on 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 ThenLoad should 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 SelectOptions type 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. Because UNION ALL does 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 with SelectOptionsStructureError.

Unchanged: The plain <Rel>(children...) method remains, and produces the same SQL as <Rel>With with 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 ThenLoadCount

Problem: bob's ThenLoadCount fills C.<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 CountEdge adds a branch to the same UNION ALL that 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 applying Then(...) to it is rejected with CountNodeThenError.

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: CountEdge supports multi-hop relationships as well as single-hop ones. The counting semantics follow bob's LoadCount exactly: 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 with CountKeyColsMismatchError rather 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.PreloadExcept or sm.Columns(...) on a child SELECT let a caller skip heavy columns, and a sectionload caller needs the same ability.

Solution: SelectOptions.Only(cols...) and SelectOptions.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 in Except is rejected with KeyColumnExcludedError instead 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's ThenLoad.

Solution: Two-part table.column references 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 and R.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 checks R.Loaded would behave differently from bob.

Solution: sectionload passes a typed nil to the generated model's Preload for 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 slot

Problem: 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.UseSchema applies to relation tables

Problem: 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 PreloadCount on the parent query is honored

Problem: bob's PreloadCount adds a correlated subquery column to the parent SELECT and uses a MapperMod to pick up that column and call the model's PreloadCount(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, and C.<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's PreloadCount(name, n) itself. Declaring both a PreloadCount and a count-only CountEdge for the same relation is rejected with PreloadCountConflictError, 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 once orm.CountPreloadColumn (#763) is merged.

12. PreloadCount columns survive row numbering of ordered parents

Problem: 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 PreloadCount columns 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 intact

Problem: 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 Preload family 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-style Preload, 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 UnsupportedMapperModsError with 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 with UncheckableParentQueryError rather than passed through unchecked.

Unchanged: PreloadCount declarations are allowed, because sectionload handles them itself.

17. Then(...) on relations with a parent-side fixed condition is rejected

Problem: A relationship can carry a fixed condition on its parent (from) side. Within one composed query this condition is applied, but across a Then(...) 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 ThenBoundaryFromWhereError before 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 ALL branch 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 hooks

Problem: A Then(...) boundary is a loader in all but name, the equivalent of a ThenLoad. 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 ALL branches 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, []byte elements have to be converted to string, because []byte is 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-psql plugin

Problem: 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 through plugins.sectionload in bob.yaml, rather than a separate command. It is off by default and generates nothing unless enabled: true is 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.go files 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 Preload append the parent to a to-many back-reference instead of overwriting it. sectionload delegates stitching to the generated Preload, 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

  • PostgreSQL only. All code lives under dialect/psql/sectionload/
    and gen/bobgen-psql/templates/sectionload/, and depends directly on
    psql-dialect packages (sm, psql.Quote, psql.Arg, etc.). Unlike
    loaders/counts, which are mirrored across gen/bobgen-mysql and
    gen/bobgen-sqlite, this PR does not add dialect/mysql/sectionload/
    or dialect/sqlite/sectionload/ equivalents — the SQL-dialect
    differences 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.
  • Multi-hop CountEdge is supported (added after single-hop-only
    support in an earlier iteration; see Testing below).
  • Preload/dedup semantics: single-hop relation dedup (whether the same
    child instance is shared across multiple parents that point to it)
    matches bob's Preload behavior by construction (both rely on the same
    semi-join structure), verified by pointer-identity tests. Multi-hop
    relations keep ThenLoad's per-parent-instance semantics rather than
    bob's Preload semantics — see the third discussion point under "Discussion points for maintainers" below for why.

Testing

  • dialect/psql/sectionload/ carries 49 test files (unit tests with no
    DB dependency, plus DB-backed tests run against a real PostgreSQL
    instance), covering SQL composition, validation, error paths, column
    restriction, PreloadCount bridging, and multi-hop count.
  • The DB-backed tests connect to a fixed local DSN (127.0.0.1:5432,
    database testdb) and t.Skipf when it 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/psql tests; switching them over is a reasonable ask if you'd
    prefer that.
  • Verified against bob v0.50.0's own tree: go build ./dialect/psql/sectionload/... ./gen/bobgen-psql/... succeeds, go vet reports nothing, gofmt -l
    reports no formatting drift, and go test ./dialect/psql/sectionload/... ./gen/bobgen-psql/templates/sectionload/... reports 256 tests passing,
    0 failing, 0 skipped (including the DB-backed tests, run against a live
    test database). The 256 figure is the count of tests passing in the
    sectionload package, measured in the source module; the
    bob-tree build check has not been re-run since. bob's own pre-existing go test ./gen/... suite passes
    unchanged, aside from one pre-existing, unrelated environment-dependent
    timeout in gen/bobgen-sqlite/driver (testcontainers-related, not
    touched by this PR).
  • Byte-identical generated output when sectionload is unused is pinned by
    golden tests; single-hop CountEdge SQL output is confirmed unchanged
    by the addition of multi-hop support.
  • Additionally verified downstream, on the application this was
    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.
  • Not yet run: actually invoking bobgen-psql end-to-end with
    plugins.sectionload.enabled: true set in a bob.yaml, to confirm the
    generator plugin produces a working dbsections-equivalent package
    from scratch. The plugin itself was exercised at the gen.StatePlugin
    unit level (PlugState), but full generator-to-output verification
    needs a project scaffold (its own go.mod, live schema) that wasn't
    set up for this submission.
  • Not run: three DB-backed PreloadCount tests that require a cgo
    PostgreSQL driver build, due to a missing C compiler in the environment
    this PR was prepared in. Everything else that needs a database ran
    against a non-cgo driver.

Discussion points for maintainers

  1. Package placement. Existing dialect/psql/ packages follow a
    short-name convention for query-clause mods (fm, sm, um, im).
    sectionload is both a longer name and a different kind of thing — an
    execution-time library, not a clause builder — and placing it at
    dialect/psql/sectionload/ is this submission's own guess rather than
    a settled convention. Alternatives (e.g. a top-level extras/sectionload/
    package) may fit better; input welcome.

  2. A future API to let callers verify a MapperMod's provenance.
    The PreloadCount bridge (countpreload.go) currently accepts a
    query's bob.MapperMod slice as count-preload-related on the basis
    that its length matches the number of CountPreloadColumn-shaped
    declarations read off the same query's PreloadColumns — a
    count-based proxy, not a proof. A caller who deliberately replaces the
    MapperMod slice via orm.Load.SetMapperMods() with unrelated
    MapperMods of the same count would not be caught by this check. We'd
    like to propose (in a follow-up, not part of this PR) giving each
    MapperMod public metadata — kind, target relationship name, and an
    opaque 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 once
    maintainers have had a chance to look at the base PR.

  3. A Preload/ThenLoad semantic inconsistency for multi-hop
    relations, unrelated to this PR's own code but surfaced while building
    it.
    For a single-hop relation, Preload's JOIN-based row
    deduplication and ThenLoad's per-parent stitching produce the same
    result (both rely on the same semi-join structure). For a multi-hop
    (through) relation, they diverge: Preload's dedup key is the final
    table'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 the
    parent's key, producing a separate instance per parent. This means
    orm/load.go's doc comment describing Preload as having "the same
    semantics as ThenLoad" holds only for single-hop relations. sectionload
    follows ThenLoad's structure and so
    inherits this divergence from Preload for multi-hop relations rather
    than 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

  • This PR was developed against a standalone generator command in
    the downstream application and then adapted to bob's bobgen-psql-integrated
    plugin model for submission; the generator-plugin file
    (gen/plugins/sectionload.go) is a from-scratch rewrite for that
    integration, not a direct port, and (as noted above) has not yet been
    exercised through a full end-to-end bobgen-psql generation run.
  • Three DB-backed tests requiring a cgo PostgreSQL driver were not run in
    the environment used to prepare this PR (no C compiler available); all
    other DB-backed tests, including the bulk of the PreloadCount and
    multi-hop count suites, were run against a live database using a non-cgo
    driver.
  • Some source files in this PR (parts.go, options.go, node.go,
    run.go, errors.go) accumulated changes across several internal
    development 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.

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 open a fixed local DSN (127.0.0.1:5432, database
testdb) and call t.Skipf when it is unreachable, so they skip in CI
where no PostgreSQL service is configured.

testdata/p05_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

Copy link
Copy Markdown
Contributor Author

Closing this one; I will resubmit it shortly as a fresh PR with the history cleaned up. Sorry for the noise.

@sandonemaki
sandonemaki deleted the sectionload branch October 11, 2026 04:56
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.

1 participant