Repository navigation
Fix PG 18 pg_query_parse_plpgsql() regressions + add regression samples - #355
pyramation wants to merge 1 commit into
Conversation
Adds test coverage (and the fixes needed for it) for the three pg_query_parse_plpgsql() regressions reported in pganalyze#337, so they are caught during future release updates: - Trigger functions emitted malformed JSON: the PLPGSQL_DTYPE_PROMISE datums created for TG_* variables were not handled in dump_function(), producing empty {} objects with mismatched braces. Serialize them via dump_var() (PROMISE datums are PLpgSQL_var structs). - Schema-qualified variable types outside pg_catalog/public returned the literal string "Not implemented (LookupExplicitNamespace ...)" instead of JSON. Fall back to the public namespace for unknown schemas (fixed in the mock so it survives extract_source). - RETURN <variable> dropped the return target: retvarno was commented out in dump_return()/dump_return_next(). Emit it when >= 0 (an expression return uses retvarno -1 and keeps its expr). Adds the three cases to test/plpgsql_samples.sql and regenerates the expected golden JSON.
|
FYI, I've been making more fixes, which I have been battle testing rigorously, potentially we should consider looking at the full diff together. I can definitely say it's working on our 23k test cases and even found a few other issues, but was working across so many repos I decided to hold off on making new PRs until the bug squashing cooled down a bit, that said, here is my diff: https://github.com/pganalyze/libpg_query/compare/18-latest...constructive-io:libpg_query:18-constructive?expand=1 |
|
I hit Bug 1 downstream and arrived at the same
One related thing. $ python3 -c "import json; json.load(open('test/plpgsql_samples.expected.json'))"
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 16 column 124 (char 23965)This PR fixes the file, but a future regeneration could adopt a similar break just as quietly. I have a small test-only change that makes
I'm happy to open it as a separate PR once this lands, or feel free to fold it in here, whichever is easier for you. For anyone working around Bug 1 downstream in the meantime: replacing |
There was a problem hiding this comment.
I feel what we do about retvarno and similar fields where the zero value is significant should be discussed separately (see my inline comment) - can you split that change out or adjust it to a plain WRITE_INT_FIELD for now?
The other two changes are good to go - sorry for the delay on this! I'd also be happy to see more PRs for other PL/pgSQL fixes you have queued up.
| WRITE_INT_FIELD(lineno, lineno, lineno); | ||
| WRITE_EXPR_FIELD(expr); | ||
| //WRITE_INT_FIELD(retvarno); | ||
| /* retvarno is -1 when an expression is returned; only emit real datums */ |
There was a problem hiding this comment.
Thinking through this more, I'm not a fan of this change having a special if (vs just WRITE_INT_FIELD), because it means we deviate with how the rest of the parse tree output is handled.
FWIW, I see the point that not emitting 0 is a bit confusing (really its a Protobuf-ism we're applying to JSON), and that -1 doesn't actually signal anything useful - but I'm not sure its the right trade-off to add code complexity here for an easier to read JSON output (and slightly easier logic for someone working with the JSON programmatically).
I think if we wanted to have those zeroes be emitted, we should instead always output zero values for JSON for both PL/pgSQL and regular parse trees. That's a bigger change that we should discuss separately, but I think its worth considering. If we did that, I was initially worried that this would break our protobuf-cpp compatibility test for regular parsetrees, but turns out that there is a flag for that to have it emit zeroes for default values - so we're good in that regard.
For context, I'd like us to move PL/pgSQL output to Protobuf+JSON in the future (along the lines of #323) and customization like this is inconvenient to maintain. It also looks like we have similar problems for another case, specifically new_varno on trigger functions, and possibly more - so we'd have to special case all of them, and keep that up going forward.
Thanks @lfittl ! Per your last comment, I work with my agent on bringing those tests cases into the repo... :)
Summary
This brings the three
pg_query_parse_plpgsql()PG 18 regressions from pganalyze/libpg_query#337 into libpg_query's own output-based test suite (test/plpgsql_samples.sql→ goldentest/plpgsql_samples.expected.json), plus the minimal fixes required for those samples to emit valid, meaningful JSON.This is a follow-up iteration on the earlier reference fix constructive-io/libpg_query#1 (
fix/plpgsql-json-serialization), which fixed the bugs but did not add regression coverage. In this comment on #337 Lukas asked:So this iteration does exactly that — the repros now live in the harness that the maintainer regenerates on every Postgres version bump, so a re-break shows up as a golden diff. Two things also changed vs. #1: the namespace fix is applied in the mock (
scripts/mocks/) so it survivesextract_source, andretvarnois emitted only for real datums instead of unconditionally.All three regressions were re-confirmed still-present on
18-latestbefore fixing (they were introduced by the PG 18 PL/pgSQL compilation rework —bc734d7"Rework how PL/pgSQL compilation is done to rely on original code more" andca4d024"Rework PL/pgSQL parsing to use proper type definitions", against Postgres 18.4).The three fixes
1. Trigger functions emitted malformed JSON — handle
PLPGSQL_DTYPE_PROMISEdatumsCause. PG 18 represents the implicit trigger variables (
tg_name,tg_when,tg_level,tg_op,tg_relid, …) as promise datums (PLPGSQL_DTYPE_PROMISE) instead of eagerly-populated vars.dump_function()'s datumswitchhad no case for that dtype, so each promise datum fell through todefault:, which emits nothing — but the surrounding loop still appended the object braces, producing{}with a mismatched trailing}for each one (…,{}},{}},{}},…). That corrupts the entiredatumsarray and makes the whole result failJSON.parse.Fix. Route promise datums to
dump_var():Why this is correct and safe. A promise datum is a
PLpgSQL_var— Postgres stores both in the same struct. From the struct's own doc comment (src/pl/plpgsql/src/plpgsql.h):So the cast
(PLpgSQL_var *) dis exactly what upstream itself does. See:struct PLpgSQL_var,PLpgSQL_datum_type,PLpgSQL_promise_type)plpgsql_compilein https://github.com/postgres/postgres/blob/REL_18_STABLE/src/pl/plpgsql/src/pl_comp.c (thetg_*vars are built, thenvar->dtype = PLPGSQL_DTYPE_PROMISE; var->promise = PLPGSQL_PROMISE_TG_*)plpgsql_fulfill_promise/ thePLPGSQL_DTYPE_PROMISEcases in https://github.com/postgres/postgres/blob/REL_18_STABLE/src/pl/plpgsql/src/pl_exec.cdump_var()only reads the leadingPLpgSQL_varfields (refname,lineno,datatype,default_val, cursor fields) — all populated for promise datums — so there is no possibility of reading uninitialized/mismatched memory. It cannot affect non-trigger functions (no promise datums), and it strictly adds previously-missing cases to aswitchwhose other arms are untouched.2. Schema-qualified types outside
pg_catalog/publicreturned an error string instead of JSONCause. The PG 18 rework resolves a schema-qualified variable type (e.g.
"my_schema".users) throughLookupExplicitNamespace(). libpg_query has no real catalog, so it stubs that function with a mock; the mockelog(ERROR, …)'d for any schema other thanpg_catalog/public, which aborts the whole parse and makespg_query_parse_plpgsql()return the literal stringNot implemented (LookupExplicitNamespace only supports pg_catalog and public)instead of JSON.Fix (
scripts/mocks/LookupExplicitNamespace.c, and the extractedsrc/postgres/src_backend_catalog_namespace.cto match):Why this is correct and safe. This does not invent a new code path — it funnels every non-catalog schema into the
publicpath that libpg_query already supports. The caller isLookupTypeNameExtended()(scripts/mocks/LookupTypeNameExtended.c), which then callsGetSysCacheOid2(TYPENAMENSP, …, namespaceId). TheGetSysCacheOidmock is already written to expect exactly this and to resolve unknown public-namespace types toRECORDOID(its own comment):So the change is behavior-preserving for the cases that already worked, and only converts former hard-errors into the pre-existing public/
RECORDOIDbehavior:pg_catalogPG_CATALOG_NAMESPACE→ builtin type oidpublicPG_PUBLIC_NAMESPACE→RECORDOIDfor unknown typesmy_schema, …)elog(ERROR), whole parse failsPG_PUBLIC_NAMESPACE→RECORDOID, same aspublicEmpirically verified —
public.usersand"my_schema".usersnow produce byte-identical AST (PLpgSQL_rec→RECORDOID), andpg_catalog.int4still resolves to the builtinint4:Because
elog(ERROR)produced no JSON at all, broadening it cannot regress any input that previously parsed successfully — the only inputs whose behavior changes are ones that used to fail outright. In a parse-only context there is no real namespace to be "wrong" about; the mock exists solely to let type resolution proceed far enough to build the AST. Relevant upstream reference for the real function's contract:LookupExplicitNamespace/LookupTypeNameExtendedin https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/catalog/namespace.c and https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/parser/parse_type.c.3.
RETURN <variable>dropped the return targetCause. For a bare variable/record return (
RETURN v,RETURN NEW), PG 18 records the target inPLpgSQL_stmt_return.retvarnorather than inexpr. Butretvarnowas commented out indump_return()/dump_return_next(), so the entire return payload disappeared from the JSON ({"PLpgSQL_stmt_return":{"lineno":4}}), making the source unreconstructable.Fix.
Why this is correct and safe.
retvarnois anintwhose sentinel-1means "this return usesexpr, not a datum" (seePLpgSQL_stmt_returnin https://github.com/postgres/postgres/blob/REL_18_STABLE/src/pl/plpgsql/src/plpgsql.h and how it is set inpl_gram.y/ consumed inexec_stmt_returnin https://github.com/postgres/postgres/blob/REL_18_STABLE/src/pl/plpgsql/src/pl_exec.c). The two representations are mutually exclusive: expression-returns keepexprand carryretvarno == -1; variable-returns carry a real datum index and noexpr. Guarding on>= 0therefore emitsretvarnoexactly for variable-returns (the ones that were losing data) and never adds a misleadingretvarno":-1to the many expression-returns. This is a deliberate refinement over PR #1, which uncommentedWRITE_INT_FIELD(retvarno,…)unconditionally and thus stamped-1onto every expression-return.Test changes
test/plpgsql_samples.sql(trigger, schema-qualified type,RETURN <var>), matching the repros in pg_query_parse_plpgsql() regressions in 18.0.0 #337.test/plpgsql_samples.expected.jsonvia the existingtest/parse_plpgsqlharness.retvarnoto a handful of pre-existing samples that doRETURN <var>/RETURN ref— expected and correct, not a behavior change beyond the fix itself.Note on the golden-file mechanism: a golden test only catches a regression if the committed baseline is correct — a snapshot of malformed JSON would simply lock the bug in. That's why the samples are paired with the fixes rather than added alone.
Verification
make test(valgrind disabled locally) — all suites pass, including theplpgsql_samplesdiff.JSON.parse-able JSON (was: throw / error-string / lossy before).pg_catalogbuiltins confirmed as shown above.