Architecture & Request Flow
A map of how a query travels from your fluent call to the database and back. File references point at the real definitions so you can jump in and read the body.
The abstract types are SQLObject, SQLObjectHandler, SQLInstruction; the concrete ones you actually hold are SQLObjectQuery (the query state), ObjectHandler (the chainable wrapper), and InstructionObject (the per-query SQL builder).
Layers (who depends on whom)
The key idea: core never names a concrete driver type. Backend.jl declares backend_* generics; the real bodies live in the ext/ weak-dependency extensions, loaded only when LibPQ, Postgres, SQLite or MySQL is present. A PostgreSQL pool names its driver (postgres_driver:), so the LibPQ and Postgres.jl extensions coexist.
Read path — the query lifecycle
The two things worth internalizing:
- Lazy until a terminal.
filter/values/order_by/...only mutate theSQLObjectQuery. No SQL exists until a terminal (list/first/get/count/exists/create/update/delete) callsquery(). - Sync wraps async.
fetch()is a thin wrapper overfetch_async(). The pool is guarded by aReentrantLock; the connection is returned to the pool inawait_resultonce the rows are materialized.
Terminals — read vs write
Every terminal funnels through the same build → parameters → Dialect → fetch machinery; only the clause it emits (SELECT vs INSERT vs UPDATE vs DELETE) differs.
Migrations — a separate flow
State-based reconciliation: compare model definitions against the live schema, emit DDL, track it.
Known architectural edges
The macro-structure above is sound — Django-shaped layers and a clean backend seam. A skeptical audit turned up no outstanding confirmed correctness bugs; the notes below are maintainability characterizations worth stating plainly.
Maintainability edges (no correctness impact)
Model-load lifecycle (the one genuine flow gap) — #65. FK / one-to-one / many-to-many / CTE target resolution happens lazily and ad-hoc in several places (the join builder resolves bindings inline via
getfield(module, …)/invokelatest, M2M scans all models, CTEs resolve their own way). There is no single "load + resolve" pass between models defined and queries/migrations run (Django'sapps.populate()analog). Highest-leverage improvement — it removes why the join builder does resolution inline, and retires the world-age-risky reflection.Join builder shape — #68 and #487, both landed. The join plan is typed:
row_joinis aVector{JoinRow}whose four kinds —ModelJoin,CteJoin,CrossJoin,AnchorlessJoin(src/querybuilder/types.jl) — select the render path by dispatch rather than by string flags._build_row_joinresolves the first hop and each subsequent hop through one_forward_fk_hop/_reverse_hop/_finish_hop!triple (src/querybuilder/build_joins.jl), so thedb_columnkey resolution has a single source of truth per hop direction; the CTE arm is first-hop only by design, and the many-to-many arms fold their own tail.Type stability is structural, not cosmetic — #41. The query state is built on
Dict{String, Any}+ heavyUnionfields, the fluent API dispatches via a runtimesym === :filterladder, SQL rendering runs throughgetfield(Module, Symbol(...))reflection per query, andfetchreturnsAny. This is baked into the core types — not fixable in function bodies. Honest nuance: low-leverage for runtime latency (it runs once per query to produce a string), but it drives TTFX/allocations and theinvokelatestworld-age fragility on the join path.Positional-parameter buckets (deliberate, contained) — for SQLite and MySQL the positional
?markers are collected into per-clause buckets and flattened in SQL-clause order (:cte → :select → :update → :join → :where → :group → :having → :order → :limit). The flatten order is single-sourced in_BUCKET_ORDER(whichget_final_parametersand the nested-run machinery both read) and guarded bytest_alignment_sqlite.jl/test_parameters.jl. Noted here so it is not mistaken for accidental coupling.Two costs, not one. Adding a new bucket is a documented multi-site edit (see the QueryBuilder skill's maintenance checklist). The subtler one: a bucket is chosen by the builder phase that binds a value, while correctness depends on where that value's
?is emitted — and those diverge whenever a fragment renders somewhere other than its own clause. Three bugs came out of that gap — #421 a relocated fragment (unrepresentable since #982: a join condition renders on its own row, in emission order) and #432 a nested render, both SQLite-only, plus #441 a discarded projection, whose parameter desync was SQLite-only but which dropped the column itself on both backends. All three were silent. A nested render now re-emits its values as one clause-ordered run at its own marker position (nested_parameter_mark/detach_nested_run!). All four such sites in the read builder go through it —Exists, a projectedSubquery, an__@insubquery, and a CTE body.deletion.jlsplices one row predicate per cascade path into a hand-builtDELETE/UPDATE(_collector_predicate). Since #765 each is the path's filters on the target row — and, when they cross a join,"Tb"."pk" IN (<selection>) AND EXISTS (<fence>), two builds of the same query: the IN for the index-driven plan, the correlated EXISTS because PostgreSQL does not re-check apk IN (SELECT …)self-subquery under a row lock. Every build goes through the same machinery, so the claim is "contained" there too — and there the wrap is load-bearing: a joined root's two builds both bind theirONvalues in:join, which unwrapped would flatten both ahead of the first build'sWHEREvalues (text order is ON₁ WHERE₁ ON₂ WHERE₂). Before #765 it was insurance only — no statement ever held two join-binding fragments, since every cascade arm is a barefilter("<fk>__@in" => parent)with no join of its own, and that is still true of cascade arms.update()'s joined branch does the same with its top-level build as the selection and one lifted fence build behind it.
For PostgreSQL the async path is genuine: the pool lock is released before the round-trip and LibPQ.async_execute is real non-blocking I/O, so queries on separate connections overlap. For SQLite, every statement is funnelled through a single global worker (serialized), with write transactions further pinned behind a process-wide lock — pool size buys no query-level concurrency. For MySQL, each statement runs on a task of its own, so queries on separate connections overlap as on PostgreSQL. "Async-first" is a real PostgreSQL and MySQL capability, not a blanket ORM property. Usage patterns live in the Async & Concurrency guide.
Where to look next
| You want to understand… | Start at |
|---|---|
How .objects and chaining work | src/querybuilder/object_manager.jl, types.jl (SQLObjectQuery, object()) |
| How SQL text is assembled | src/querybuilder/build_query.jl (build), build_select.jl (SELECT / ORDER BY), build_filter.jl (WHERE / HAVING), select_nodes.jl / filter_nodes.jl / filter_operators.jl (one node at a time), projection_types.jl, build_joins.jl, ctes.jl |
Custom joins — on / cjoin / cjoin_on, and what their conditions refer to | src/querybuilder/join_conditions.jl |
How an F(...) expression, a date shift or an interval renders | src/querybuilder/expression_render.jl |
Parameter ordering (PG $1 vs SQLite/MySQL ? buckets) | src/querybuilder/parameters.jl |
| Backend-specific SQL | src/Dialect.jl |
| Pool, async, transactions | src/ConnectionPool.jl |
| Driver bodies | ext/PormGLibPQExt.jl, ext/PormGPostgresExt.jl, ext/PormGSQLiteExt.jl, ext/PormGMySQLExt.jl |
| Schema reconciliation | src/Migrations.jl, src/migrations/ |