From 043af111c33340935ed1b5597056d16692776ffb Mon Sep 17 00:00:00 2001 From: tianzhou Date: Tue, 24 Feb 2026 19:01:03 -0800 Subject: [PATCH] fix: quote reserved keywords after schema prefix stripping in function bodies (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When stripSchemaPrefixFromBody removes schema qualifiers from function, procedure, and view bodies (e.g., public.user → user), the remaining identifier may be a reserved keyword that produces invalid SQL without quoting. Now checks identifiers after prefix removal and quotes them when needed (e.g., public.user → "user", public.order → "order"). Co-Authored-By: Claude Opus 4.6 --- cmd/dump/dump_integration_test.go | 7 ++ ir/normalize.go | 14 +++ .../manifest.json | 10 +++ .../pgdump.sql | 86 +++++++++++++++++++ .../pgschema.sql | 53 ++++++++++++ .../raw.sql | 39 +++++++++ 6 files changed, 209 insertions(+) create mode 100644 testdata/dump/issue_320_plpgsql_reserved_keyword_type/manifest.json create mode 100644 testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgdump.sql create mode 100644 testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgschema.sql create mode 100644 testdata/dump/issue_320_plpgsql_reserved_keyword_type/raw.sql diff --git a/cmd/dump/dump_integration_test.go b/cmd/dump/dump_integration_test.go index 70a35847..5b8f9252 100644 --- a/cmd/dump/dump_integration_test.go +++ b/cmd/dump/dump_integration_test.go @@ -116,6 +116,13 @@ func TestDumpCommand_Issue307ViewDependencyOrder(t *testing.T) { runExactMatchTest(t, "issue_307_view_dependency_order") } +func TestDumpCommand_Issue320PlpgsqlReservedKeywordType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + runExactMatchTest(t, "issue_320_plpgsql_reserved_keyword_type") +} + func TestDumpCommand_Issue318CrossSchemaComment(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") diff --git a/ir/normalize.go b/ir/normalize.go index 1d95ce4e..b9eb9d7d 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -388,6 +388,20 @@ func stripSchemaPrefixFromBody(body, schema string) string { // Ensure this is a schema qualifier, not part of a longer identifier // (e.g., "not_public.users" should not match) if i == 0 || !isIdentChar(body[i-1]) { + // After stripping the schema prefix, check if the remaining identifier + // is a reserved keyword that needs quoting. + // e.g., public.user → "user", public.order → "order" + afterPrefix := i + prefixLen + identEnd := afterPrefix + for identEnd < len(body) && isIdentChar(body[identEnd]) { + identEnd++ + } + ident := body[afterPrefix:identEnd] + if needsQuoting(ident) { + result.WriteString(QuoteIdentifier(ident)) + i = identEnd - 1 + continue + } // Skip the schema prefix, keep everything after it i += prefixLen - 1 continue diff --git a/testdata/dump/issue_320_plpgsql_reserved_keyword_type/manifest.json b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/manifest.json new file mode 100644 index 00000000..1769d3c8 --- /dev/null +++ b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "issue_320_plpgsql_reserved_keyword_type", + "description": "Test case for reserved keywords becoming unquoted after schema prefix stripping in function/procedure bodies (GitHub issue #320)", + "source": "https://github.com/pgplex/pgschema/issues/320", + "notes": [ + "Reproduces the bug where schema-qualified reserved keywords (e.g., app.user) lose quoting when the schema prefix is stripped", + "Tests that reserved keyword identifiers are properly double-quoted after schema prefix removal in DECLARE sections and SQL statements", + "Tests that non-reserved identifiers remain unquoted after schema prefix removal" + ] +} diff --git a/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgdump.sql b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgdump.sql new file mode 100644 index 00000000..4da019c5 --- /dev/null +++ b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgdump.sql @@ -0,0 +1,86 @@ +-- +-- PostgreSQL database dump +-- + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: user; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."user" ( + id integer NOT NULL, + name text NOT NULL, + email text +); + +-- +-- Name: user_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +-- +-- Name: user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; + +-- +-- Name: user id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); + +-- +-- Name: user user_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_pkey PRIMARY KEY (id); + +-- +-- Name: get_first_user(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.get_first_user() RETURNS text + LANGUAGE plpgsql + AS $$ +DECLARE + account public.user; +BEGIN + SELECT * INTO account FROM public.user LIMIT 1; + RETURN account.name; +END; +$$; + +-- +-- Name: count_users(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.count_users() RETURNS integer + LANGUAGE plpgsql + AS $$ +DECLARE + total_count integer; +BEGIN + SELECT count(*)::integer INTO total_count FROM public."user"; + RETURN total_count; +END; +$$; + +-- +-- PostgreSQL database dump complete +-- diff --git a/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgschema.sql b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgschema.sql new file mode 100644 index 00000000..b3488ffd --- /dev/null +++ b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/pgschema.sql @@ -0,0 +1,53 @@ +-- +-- pgschema database dump +-- + +-- Dumped from database version PostgreSQL 18.0 +-- Dumped by pgschema version 1.7.2 + + +-- +-- Name: user; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS "user" ( + id SERIAL, + name text NOT NULL, + email text, + CONSTRAINT user_pkey PRIMARY KEY (id) +); + +-- +-- Name: count_users(); Type: FUNCTION; Schema: -; Owner: - +-- + +CREATE OR REPLACE FUNCTION count_users() +RETURNS integer +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + total_count integer; +BEGIN + SELECT count(*)::integer INTO total_count FROM "user"; + RETURN total_count; +END; +$$; + +-- +-- Name: get_first_user(); Type: FUNCTION; Schema: -; Owner: - +-- + +CREATE OR REPLACE FUNCTION get_first_user() +RETURNS text +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + account "user"; +BEGIN + SELECT * INTO account FROM "user" LIMIT 1; + RETURN account.name; +END; +$$; + diff --git a/testdata/dump/issue_320_plpgsql_reserved_keyword_type/raw.sql b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/raw.sql new file mode 100644 index 00000000..7f1678cc --- /dev/null +++ b/testdata/dump/issue_320_plpgsql_reserved_keyword_type/raw.sql @@ -0,0 +1,39 @@ +-- +-- Test case for GitHub issue #320: Reserved keywords after schema prefix stripping +-- +-- When a function body contains schema-qualified references like public.user, +-- stripping the schema prefix should produce "user" (quoted) since user is +-- a reserved keyword. Without quoting, the dumped SQL is syntactically invalid. +-- + +CREATE TABLE "user" ( + id serial PRIMARY KEY, + name text NOT NULL, + email text +); + +-- Function using schema-qualified reserved keyword type in DECLARE and body +CREATE OR REPLACE FUNCTION get_first_user() +RETURNS text +LANGUAGE plpgsql +AS $$ +DECLARE + account public.user; +BEGIN + SELECT * INTO account FROM public.user LIMIT 1; + RETURN account.name; +END; +$$; + +-- Function with non-reserved type name (should NOT be affected) +CREATE OR REPLACE FUNCTION count_users() +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + total_count integer; +BEGIN + SELECT count(*)::integer INTO total_count FROM public."user"; + RETURN total_count; +END; +$$;