Repository navigation
Proposals from the OneSales/CCMT2/MDMApp rollout: fetch_one/fetch_scalar/execute, pg_retrieve caching, lazy pool, readonly_transaction, ... #51
Description
Proposals collected while adopting fetch_all / PostgresJsonResponse (0.14.x) in OneSales, CCMT2 and MDMApp. Each item says where the need showed up; sizes are from the MDMApp survey unless noted. This list will be extended when the OneSales/CCMT2 rollouts report.
1. fetch_one, fetch_scalar, execute
fetch_all has no siblings, so repos keep their own (OneSales: fetch_one/fetch_scalar in backend/db/loader.py, still used in ~25 modules) or hand-written cursor code (MDMApp: ~160 fetchone() sites in 59 files).
- Same keyword set as
fetch_all:model=/row_mapper=,con=/pool=,cancel=,statement_timeout=; t-string overloads withoutparams. fetch_one(...) -> T | None,fetch_scalar(...) -> Any | None(first column of the first row),execute(...) -> int(rowcount) for writes withoutRETURNING.bdt lintalready treatsfetch_one/fetch_scalarlike.execute()(devtools 0.33), so lint coverage is in place once they exist.
2. pg_retrieve(complex_helper=...) re-queries the catalog on every call
MDMApp measured 5 queries per call on imp.file_import instead of 2: ComplexHelper re-reads pg_type/information_schema and registers every enum and composite on the connection each time, with no caching per connection or pool. MDMApp reverted to its own pg_retrieve because of this.
- Cache the loaded type info (and the "already registered on this connection" state) in
ComplexHelper, keyed per connection/pool; or accept explicitcomplex_fields=to skip discovery.
3. pg_retrieve_many only supports equality filters
MDMApp's import_admin.py still builds its own select list because it needs more than col = value.
- Add
where=(apsycopg.sqlcomposable + params / t-string),order_by=,limit=; or at least expose the select-list builder (ComplexHelper-aware) publicly so callers can compose their own query.
4. Lazy default pool (no per-request await open_pool())
MDMApp akeneo_editor calls await open_pool() ~195 times in 49 files (per request/function). A default pool that opens lazily on first use (and closes via an explicit close), or an ensure_open() on PgPool, would remove all of it. Related: OneSales had to write a tiny adapter class to register its own module-level pool with set_default_pool(); accepting a plain Callable[[], AsyncContextManager[AsyncConnection]] as well as a ConnectionSource would avoid that.
5. readonly_transaction(con) helper
OneSales (backend/db/postgres.py) has a generic helper that guards LLM-supplied/untrusted SQL: await con.set_read_only(True) before the transaction and a finally that resets it to None, because the connection goes back to a shared pool (a leaked read_only would turn an unrelated write into ReadOnlySqlTransaction). Generic and easy to get wrong, so worth shipping in pgdevkit.db.
6. statement_timeout semantics (docs or option)
fetch_all/PostgresJsonResponse apply statement_timeout with set_config(..., is_local), which overrides the database-level setting in both directions. CCMT2 has a deliberate 600 s DB-level safety net (database/_migration_scripts/2026-07-16_statement_timeout_safety_net.sql), so a per-call 120 s loosens nothing but a per-call value above 600 s would. Document this, and consider a "never loosen" mode (min(current, requested)).
7. Literal % in sqlglot expressions when params is passed
sqlglot's postgres dialect doesn't escape %, and psycopg parses % whenever params is not None, so LIKE '50%' + params raises (documented as a limitation in the fetch_all docstring). CCMT2 works around it with a custom FixedPostgres dialect that always doubles % (and then has to pass {} instead of None, because psycopg only un-doubles %% when params is not None). Consider doing this properly inside _render (escape % in rendered string literals when params is not None).
8. Smaller notes
PostgresJsonResponseholds a pooled connection while the client reads (MDMApp pool = 40): worth a sentence in the docs next to the proxy-write-timeout note.- Error body shape: streamed endpoints return
{"error": ...}where FastAPI handlers return{"detail": ...}; maybe an option fordetail, so existing frontends keep working without changes. timestamptzserializes as+00:00viarow_to_json, where Pydantic/FastAPI emitZ: worth a docs line for frontend consumers.
Claude Session: dd3520fe-363b-5088-ab76-5e87fad68be0
Activity
aersam commentedon Oct 9, 2026
Working on this in session dd3520fe-363b-5088-ab76-5e87fad68be0 — draft PR: #52 (items 1, 2, 3, 5, 6 (opt-in), 7, 8 implemented; 4 partly, see PR).
aersam commentedon Oct 9, 2026
Additions from the OneSales rollout (PR bmsuisse/OneSales on Azure DevOps, 32429):
PgPooloptions: OneSales could not replace its own pool withPgPoolbecausePgPoolexposes noconfigurehook (statement/idle timeouts set on each new connection), no acquiretimeout, nomin_size, nocheckoverride, and noapplication_name/TLS knobs beyonddsn_params. Proposal: pass-through ofmin_size,timeout,configure,check, andkwargsto the underlying psycopg pool. (The unquoted password inPgPool._dsnis a bug, filed separately.)pg_*CRUD helpers buildComplexHelper(con)by hand at every call site: OneSales has four thin wrappers (pg_insert,pg_update_dict,pg_upsert_dict,pg_insert_many) that only bind aComplexHelper. Proposal: create/cache one automatically whencomplex_helperis omitted (ties in with item 2, caching), or acceptcomplex_helper="auto".where_and(conditions): OneSales has ~9 hand-written" and ".join(...)optional-filter builders (e.g.task_repository.list_tasks). A small helper returning apsycopg.sqlfragment (+ params) from a list of optional conditions would remove the string joining (and its lint noise).paginate(query, limit, offset, order_by=): ~5 limit/offset boilerplates with a stable ordering tiebreak.readonly_transactionvariant: besides the context manager from item 5, OneSales would usefetch_all(..., read_only=True)for LLM-supplied SQL.PostgresJsonResponse:row_to_jsonemits NaN/Infinity as the strings"NaN"/"Infinity"; document, or offer a normalisation option.error; a configurable key (ordetail) would let existing frontends keep reading.detail(see item 8).BaseHTTPMiddlewarea failure after the first byte still ends as a clean 200 with a truncated body (the closing]is missing, so clients can detect it, but status can't); worth documenting next to the middleware caveat.fetch_oneand 11fetch_scalarcall sites.aersam commentedon Oct 9, 2026
PR #52 is ready for review (CI green, version 0.15.0, also Fixes #53).
Done: 1
fetch_one/fetch_scalar/execute; 2 ComplexHelper per-connection cache (+complex_helper="auto"on thepg_*helpers); 3pg_retrieve_many(where=, params=, order_by=, limit=); 4 (partly)set_default_poolaccepts a plain callable; 5readonly_transaction; 6 opt-instatement_timeout=at_most(s); 7%escaping for sqlglot expressions; 8 docs (pool exhaustion,errorvsdetail+error_key,+00:00vsZ, NaN/Infinity, middleware caveat); OneSales:PgPoolpass-through ofmin_size/timeout/configure/check.Deferred / proposed in the PR description: lazy default pool (lifecycle question),
where_and,paginate,fetch_all(read_only=True)(note:readonly_transactionis a safety net, not a sandbox, because multi-statement text canCOMMITout of it; documented), explicitcomplex_fields=.aersam commentedon Oct 9, 2026
Additions from the CCMT2 rollout (CCMT2 PR 32427 on Azure DevOps). Items that overlap with #52 are marked.
fetch_one/fetch_scalar(Proposals from the rollout (fetch_one/fetch_scalar/execute, ComplexHelper cache, readonly_transaction, ...) #52): CCMT2 has 77fetchone()sites, 48 of them the simple single-statement form (13 inbackend/api/bonus_rules.py, 8 each inbonus_customer_groups.pyandbonus_admin.py).{k: v for k, v in prms.items() if k in sql_p.params}is repeated at 23 sites (queries are built from sqlglot ASTs that only use some of the available params; psycopg ignores extra dict keys but sqlglot-style callers carry large shared param dicts anyway), and the samePlaceholderwalk backsget_sql_with_prm_list(53 uses). Proposal:SqlLoader.load_sql_with_params(topic, name) -> (sql, param_names)and/or a helper that filters a params dict to the names a query uses.split_sql(database/proc_schema.py:19, dollar-quote aware) and_split_sql_statements(tests/create_test_db.py:27, naive) are two copies of the same idea. Proposal:pgdevkit.sql_text.split_statements(sql)(dollar-quote and comment aware; pgdevkit already hassql_text.py).get_sql_replace_paramsrenders a query with its params inlined (used by an endpoint'sdebug_sqlflag). Proposal:render_with_params(query, params)(dev/test diagnostics only, never for execution) and/orPostgresJsonResponse.debug_sql().params=None+%%: psycopg only un-doubles%%whenparams is not None; CCMT2's custom dialect doubles%always, so its callers must pass{}instead ofNone. Proposals from the rollout (fetch_one/fetch_scalar/execute, ComplexHelper cache, readonly_transaction, ...) #52 escapes%itself when params are given; confirm thatNonevs{}can't differ for string queries (normaliseNoneto{}when the query contains%%, or document it).FixedPostgresdialect and hands pgdevkit a string wrapped inSQL(...), which bypasses theexp.Command/placeholder-name guards in_render. If Proposals from the rollout (fetch_one/fetch_scalar/execute, ComplexHelper cache, readonly_transaction, ...) #52's%escaping makes the custom dialect unnecessary, say so in the docs so apps can drop theirs; otherwise offer adialect=parameter.aersam commentedon Oct 9, 2026
MDMApp rollout, pgdevkit 0.15.0: findings.
1.
pg_retrieve/pg_retrieve_many(with aComplexHelper) register every enum/composite of the table on the connection, which changes what other code on that pooled connection reads (bug / design gap)._select_listwraps complex columns into_jsonb(...), so the result does not need any registered type, butload_all_complex_types->_recurse_registerstill callsregister_enum(info, con)/register_composite. That registration is global for the connection and sticks to it in the pool. Afterwards any plainSELECTof that enum type on the same connection returns psycopg's auto-numberedEnummember instead of the label:In MDMApp this made unrelated endpoints/tests return
3instead of"done"depending on pool order (3 tests failed in the full akeneo_editor suite, all passing in isolation), so I could not adoptpg_retrieve*there (kept mdm_common's own, which only touches jsonb/dict columns). Suggestion: don't register types for the read path that already goes throughto_jsonb(only register where a Python value must be converted for a write), or register enums with astr-valued enum (register_enum(info, con, enum=<StrEnum>)) / not at all (the default text loader already returns labels), and document thatregister_*is connection-global.2. Not generic enough to need new API, but worth a doc line:
fetch_scalar/fetch_oneusetuple_row/dict_rowand are therefore affected by (1) too;complex_helper="auto"would widen the blast radius of (1) to everypg_*call.3. Still missing for the ~160
fetchonesites: nothing blocking:fetch_one/fetch_scalar/executecovered 31 sites mechanically (DELETE/INSERT ... RETURNING included, since the pool commits on exit). Sites that remain are multi-statement / transactional, or tests that countget_pg_connectioncheckouts (they patch the module attribute, whichfetch_*via the default pool bypasses, an intentional trade-off there).4.
readonly_transaction,at_most,PostgresJsonResponset-strings: adoptedat_mostfor the streamed endpoints; nothing to report.aersam commentedon Oct 9, 2026
CCMT2 rollout (PR 32427 on pgdevkit 0.15.0), new generic gaps:
bdt lintdoes not checkexecute(): an f-string passed toexecute(...)(CCMT2_write_rule_change,UPDATE ... SET {set_clause}) is not flagged, althoughfetch_*is. The same SQL rules should apply toexecute(devtools).fetch_one/fetch_scalarcover the dict-row and single-value cases (48 sites converted). Remaining cursor blocks that need a helper:cursor.executemanyandcursor.copy(bulk insert inbackend/sql/postgres_schema.py), and several statements in one transaction with the cursor reused (e.g.INSERT ... ON CONFLICTfollowed by a conditional second insert onrowcount). Apgdevkit.db.transaction(pool)async context manager yieldingconwould make theget_pg_connection()+ commit boilerplate unnecessary at ~90async with get_pg_connection() as consites.FixedPostgres(own sqlglot dialect) is still needed in CCMT2 only because stock sqlglot rendersArrayContainsasCASE WHEN ... COALESCE(x = ANY(col), FALSE)instead ofx = ANY(col); not pgdevkit's concern, mentioning it because_renderuses the plainpostgresdialect (adialect=argument on fetch_*/PostgresJsonResponse would let such apps drop their own render step).{k: v for k, v in prms.items() if k in sql_p.params}, 23 sites):fetch_all(..., ignore_extra_params=True)orSqlLoaderreturning the parameter names would remove it.