Skip to content

Render JSON path keys as inline literals - #134

Open
jonassvalin wants to merge 2 commits into
mainfrom
literal-json-path-keys
Open

jonassvalin wants to merge 2 commits into
mainfrom
literal-json-path-keys

Conversation

@jonassvalin

Copy link
Copy Markdown
Contributor

Summary

The Postgres query converters now render JSON path keys inline, as jsonb_extract_path("state", 'key'), instead of as bind parameters. Expression indexes can then be used under generic (prepared) plans. This also fixes filters and sorts on paths with integer segments, which currently fail in Postgres.

This is the second of four PRs from the plan in #132. It doesn't depend on #132. The README paragraph for this change extends the section #132 adds, so I'll add it once #132 merges.

Motivation

psycopg prepares a statement after it has run five times on a connection. Postgres may then switch to a generic plan, where a bound key renders as jsonb_extract_path(state, VARIADIC ARRAY[$2]). No expression index can match that expression, so the planner falls back to the wrong index or a sequential scan. A downstream service saw 4.3s per list query locally in this state. With the key inline, the expression folds to the same constant as an index defined on jsonb_extract_path(state, 'key'), and the index matches.

Integer path segments, such as Path("state", "values", 0), are allowed by Path but fail today. psycopg binds the int as smallint, and Postgres has no jsonb_extract_path(jsonb, unknown, smallint). Rendered as the text '0', the segment indexes into the array as intended.

Changes

Key Changes

  • InlineLiteral query node: renders a scalar inline through psycopg's quoting, with no params. It isn't exported from the package.
  • expression_for_path renders every path sub-level as an InlineLiteral. This covers filters (including the jsonb_extract_path_text forms), sorts, similarity and key-set paging.
  • Integer sub-levels render as text array indices. bool sub-levels raise ValueError, rather than silently rendering as 'True'.

Implementation Details

  • % handling. Every adapter calls cursor.execute(query, params) with a params list, so psycopg scans the whole SQL text, inline literals included, for % placeholders. Without escaping, a key like '50%x' raises ProgrammingError, and 'a%%b' silently becomes 'a%b', so the query reads a different key. InlineLiteral therefore doubles %, and psycopg collapses it back when it binds the query.
  • Quoting. Quotes and backslashes are handled by sql.Literal.
  • Statement counts. Path keys come from code, so each query shape still maps to one statement text, and fewer parameters are sent.

Breaking Changes

  • The SQL text and parameter list returned by QueryConverter.convert_query change for nested paths: path keys move from the params into the SQL. Tests or code that assert on, or post-process, rendered queries need updating.
  • A Path with a bool sub-level now raises ValueError instead of failing in Postgres.

Migration Guide

Update any expectations on rendered SQL. For example:

  • the SQL "jsonb_extract_path"("state", %s) = … with params ["value", 5]
  • becomes "jsonb_extract_path"("state", 'value') = … with params [5].

Existing expression indexes keep matching. Under custom plans the expressions are identical once parameters are substituted, and under generic plans they now match where they didn't before.

How to Verify

Automated Verification

  • All tests pass: unit 1763, integration 139, component 3
  • Type checking passes: mise run types:check
  • Linting passes: mise run lint:check
  • Formatting is correct: mise run format:check

New tests:

  • InlineLiteral renders plain values, quotes, backslashes, %, %s and %% inline with no params, including as function arguments.
  • All nested-path converter expectations now carry their keys inline. New cases cover:
    • a key containing a quote and %;
    • bool sub-level rejection;
    • key-set paging with a nested sort, forwards and backwards.
  • Shared adapter cases, run against both in-memory and Postgres:
    • Filter on a list element by index: fails on main with the smallint error.
    • Sort on a two-level nested path: a regression guard.
    • Filter on a key containing ', %s and %%: a regression guard; it proves escaping round-trips through Postgres.
  • tests/integration/.../persistence/postgres/test_query_plans.py:
    • Seeds 5,000 rows, adds a (name, jsonb_extract_path(state, 'kind')) index, runs ANALYZE, and checks the plan with EXPLAIN (GENERIC_PLAN, FORMAT JSON).
    • Asserts the index's Index Cond contains jsonb_extract_path, not just that the index is used, since it could be scanned on name alone.
    • With the previous rendering restored, this test fails.
    • A permanent negative control runs the old bound-key SQL and asserts the expression isn't used as an index condition.

Manual Verification

  • Downstream service on a local build: tests pass after updating its rendered-SQL expectations, and its list query uses its expression index under plan_cache_mode = force_generic_plan.

Checklist

Related Issues

None. The plan and its review are in #132, under meta/plans/ and meta/reviews/plans/.

Renders a scalar value into the SQL text via psycopg's quoting instead of
as a bind parameter. Percent signs are doubled because psycopg re-parses
composed queries for placeholders whenever parameters are passed, which
would otherwise reject or silently rewrite values containing '%'.
expression_for_path now renders path sub-levels as text literals rather
than bind parameters, so jsonb_extract_path("state", 'key') matches
expression indexes under generic plans, which psycopg's automatic
statement preparation can lead Postgres to use.

Integer sub-levels are rendered as text, which jsonb_extract_path treats
as array indices. Previously they were bound as smallint and failed with
"function jsonb_extract_path(jsonb, unknown, smallint) does not exist".
Boolean sub-levels are rejected rather than rendered as 'True'.

This branch has not been deployed

No deployments
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