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.

Type naming

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)

flowchart TD U["Your code<br/>M.Result.objects.filter(...).values(...).list()"] subgraph API["Public API"] OBJ["object(model) → ObjectHandler<br/>wraps SQLObjectQuery (query state)<br/>querybuilder/types.jl"] CHAIN["fluent methods: .filter() / .values()<br/>.order_by() / .limit() / .with() ...<br/>querybuilder/object_manager.jl"] end subgraph QB["QueryBuilder — src/querybuilder/"] Q["query() — execution_read.jl"] B["build() → InstructionObject<br/>build_query.jl"] J["_build_row_join (joins)<br/>build_joins.jl<br/>on / cjoin / cjoin_on — join_conditions.jl"] P["parameters.jl<br/>$1.. (PG) / ? buckets (SQLite, MySQL)"] F["functions.jl / operators.jl / ctes.jl<br/>expression_render.jl — F(...) / date / interval SQL"] end DI["Dialect.jl<br/>per-backend SQL rendering"] subgraph CP["ConnectionPool.jl"] FE["fetch() → fetch_async()"] POOL["pool + ReentrantLock<br/>acquire / release"] end BK["Backend.jl<br/>backend_* generics (no driver type named)"] subgraph EXT["weakdep extensions — ext/"] PG["PormGLibPQExt.jl → LibPQ"] PJ["PormGPostgresExt.jl → Postgres"] SL["PormGSQLiteExt.jl → SQLite"] MY["PormGMySQLExt.jl → MySQL"] end DB[("PostgreSQL / SQLite / MySQL")] U --> OBJ --> CHAIN --> Q --> B B --> J B --> P B --> F B --> DI Q --> FE --> POOL --> BK BK --> PG --> DB BK --> PJ --> DB BK --> SL --> DB BK --> MY --> DB DB -->|rows| FE -->|results| U

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

sequenceDiagram autonumber participant App as Your code participant OH as ObjectHandler participant EX as execution_read.jl participant BQ as build_query.jl participant CP as ConnectionPool participant BE as Backend + ext participant DB as Database App->>OH: .objects.filter(...).values(...) Note over OH: each call MUTATES the SQLObjectQuery<br/>state (filter, values, order, joins, ctes...)<br/>nothing hits the DB yet — it is lazy App->>EX: .list() (terminal) EX->>EX: query() — resolve settings + param collector EX->>BQ: build(object) BQ->>BQ: get_select_query / get_filter_query<br/>_build_row_join / order / build_cte_clause Note over BQ: fills an InstructionObject:<br/>SQL text + ordered parameters BQ-->>EX: SQL string + parameters EX->>CP: fetch(conn, sql, params) CP->>CP: fetch_async() → acquire_connection (under lock) CP->>BE: backend_execute_async(conn, sql, params) BE->>DB: $1.. (PG) / ? (SQLite, MySQL) DB-->>BE: rows BE-->>CP: result CP->>CP: await_result → release connection CP-->>EX: rows EX-->>App: Vector / DataFrame / dicts

The two things worth internalizing:

  1. Lazy until a terminal. filter/values/order_by/... only mutate the SQLObjectQuery. No SQL exists until a terminal (list/first/get/count/exists/create/update/delete) calls query().
  2. Sync wraps async. fetch() is a thin wrapper over fetch_async(). The pool is guarded by a ReentrantLock; the connection is returned to the pool in await_result once the rows are materialized.

Terminals — read vs write

flowchart LR subgraph Read L[".list() / .first() / .get()"] C[".count() / .exists()"] end subgraph Write CR[".create()"] UP[".update()"] DE[".delete()"] end L --> QRY["query() → build() → fetch()"] C --> QRY CR --> INS["insert path<br/>execution_write.jl / execution_bulk.jl"] UP --> UPD["update path<br/>execution_write.jl"] DE --> DEL["deletion.jl<br/>cascade planning (CASCADE/SET_NULL/...)"] QRY --> FE["fetch()"] INS --> FE UPD --> FE DEL --> FE FE --> DB[("DB")]

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.

flowchart TD M["models (db/models.jl)<br/>PormGModel definitions"] MM["makemigrations()<br/>migrations/planner.jl"] INTRO["introspection.jl<br/>read live DB schema"] DIFF["diff: desired models vs current schema"] FILE["migration .jl file<br/>(OrderedDict of SQL DDL)"] MIG["migrate()<br/>migrations/runner.jl"] LOCK["advisory lock (PG) / GET_LOCK (MySQL) / write-lock (SQLite)"] DDL["Dialect.jl renders CREATE / ALTER / DROP"] TRACK["pormg_migrations table<br/>version + checksum + status"] DB[("Database")] M --> MM M --> DIFF MM --> INTRO --> DIFF --> FILE FILE --> MIG --> LOCK --> DDL --> DB MIG --> TRACK

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's apps.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_join is a Vector{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_join resolves the first hop and each subsequent hop through one _forward_fk_hop / _reverse_hop / _finish_hop! triple (src/querybuilder/build_joins.jl), so the db_column key 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} + heavy Union fields, the fluent API dispatches via a runtime sym === :filter ladder, SQL rendering runs through getfield(Module, Symbol(...)) reflection per query, and fetch returns Any. 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 the invokelatest world-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 (which get_final_parameters and the nested-run machinery both read) and guarded by test_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 projected Subquery, an __@in subquery, and a CTE body.

    deletion.jl splices one row predicate per cascade path into a hand-built DELETE/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 a pk 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 their ON values in :join, which unwrapped would flatten both ahead of the first build's WHERE values (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 bare filter("<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.

\"Async-first\" is backend-specific

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 worksrc/querybuilder/object_manager.jl, types.jl (SQLObjectQuery, object())
How SQL text is assembledsrc/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 tosrc/querybuilder/join_conditions.jl
How an F(...) expression, a date shift or an interval renderssrc/querybuilder/expression_render.jl
Parameter ordering (PG $1 vs SQLite/MySQL ? buckets)src/querybuilder/parameters.jl
Backend-specific SQLsrc/Dialect.jl
Pool, async, transactionssrc/ConnectionPool.jl
Driver bodiesext/PormGLibPQExt.jl, ext/PormGPostgresExt.jl, ext/PormGSQLiteExt.jl, ext/PormGMySQLExt.jl
Schema reconciliationsrc/Migrations.jl, src/migrations/