From 869568b1724fad54008a68e6ba65b8a19bccc205 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Tue, 14 Oct 2025 23:59:16 +0800 Subject: [PATCH] fix: index name quotation --- cmd/dump/dump_integration_test.go | 7 + internal/diff/index.go | 2 +- internal/plan/rewrite.go | 10 +- .../issue_80_index_name_quote/manifest.json | 8 + .../dump/issue_80_index_name_quote/pgdump.sql | 140 ++++++++++++++++++ .../issue_80_index_name_quote/pgschema.sql | 93 ++++++++++++ .../dump/issue_80_index_name_quote/raw.sql | 84 +++++++++++ 7 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 testdata/dump/issue_80_index_name_quote/manifest.json create mode 100644 testdata/dump/issue_80_index_name_quote/pgdump.sql create mode 100644 testdata/dump/issue_80_index_name_quote/pgschema.sql create mode 100644 testdata/dump/issue_80_index_name_quote/raw.sql diff --git a/cmd/dump/dump_integration_test.go b/cmd/dump/dump_integration_test.go index 90938675..a7d574c3 100644 --- a/cmd/dump/dump_integration_test.go +++ b/cmd/dump/dump_integration_test.go @@ -54,6 +54,13 @@ func TestDumpCommand_Issue78ConstraintNotValid(t *testing.T) { runExactMatchTest(t, "issue_78_constraint_not_valid") } +func TestDumpCommand_Issue80IndexNameQuote(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + runExactMatchTest(t, "issue_80_index_name_quote") +} + func runExactMatchTest(t *testing.T, testDataDir string) { runExactMatchTestWithContext(t, context.Background(), testDataDir) } diff --git a/internal/diff/index.go b/internal/diff/index.go index 8a231ce1..0430e7a3 100644 --- a/internal/diff/index.go +++ b/internal/diff/index.go @@ -81,7 +81,7 @@ func generateIndexSQLWithName(index *ir.Index, indexName string, targetSchema st builder.WriteString("IF NOT EXISTS ") // Index name - builder.WriteString(indexName) + builder.WriteString(ir.QuoteIdentifier(indexName)) builder.WriteString(" ON ") // Table name with proper schema qualification diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index 4c845aea..95e58839 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -123,8 +123,8 @@ func generateIndexChangeRewriteFromIndex(index *ir.Index) []RewriteStep { waitSQL := generateIndexWaitQueryWithName(tempIndexName) // Drop old index and rename new one - dropSQL := fmt.Sprintf("DROP INDEX %s;", index.Name) - renameSQL := fmt.Sprintf("ALTER INDEX %s RENAME TO %s;", tempIndexName, index.Name) + dropSQL := fmt.Sprintf("DROP INDEX %s;", ir.QuoteIdentifier(index.Name)) + renameSQL := fmt.Sprintf("ALTER INDEX %s RENAME TO %s;", ir.QuoteIdentifier(tempIndexName), ir.QuoteIdentifier(index.Name)) return []RewriteStep{ { @@ -162,8 +162,8 @@ func generateIndexChangeRewrite(indexDiff *diff.IndexDiff) []RewriteStep { waitSQL := generateIndexWaitQueryWithName(tempIndexName) // Drop old index and rename new one - dropSQL := fmt.Sprintf("DROP INDEX %s;", indexDiff.Old.Name) - renameSQL := fmt.Sprintf("ALTER INDEX %s RENAME TO %s;", tempIndexName, indexDiff.New.Name) + dropSQL := fmt.Sprintf("DROP INDEX %s;", ir.QuoteIdentifier(indexDiff.Old.Name)) + renameSQL := fmt.Sprintf("ALTER INDEX %s RENAME TO %s;", ir.QuoteIdentifier(tempIndexName), ir.QuoteIdentifier(indexDiff.New.Name)) return []RewriteStep{ { @@ -322,7 +322,7 @@ func generateIndexSQL(index *ir.Index, isConcurrent bool) string { sql.WriteString(" CONCURRENTLY") } sql.WriteString(" IF NOT EXISTS ") - sql.WriteString(index.Name) + sql.WriteString(ir.QuoteIdentifier(index.Name)) sql.WriteString(" ON ") tableName := getTableNameWithSchema(index.Schema, index.Table) diff --git a/testdata/dump/issue_80_index_name_quote/manifest.json b/testdata/dump/issue_80_index_name_quote/manifest.json new file mode 100644 index 00000000..a68f4dc0 --- /dev/null +++ b/testdata/dump/issue_80_index_name_quote/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "issue_80_index_name_quote", + "description": "Test case for index name quoting (GitHub issue #80)", + "source": "https://github.com/pgschema/pgschema/issues/80", + "notes": [ + "Tests that index names with spaces, special characters, and mixed case are properly quoted" + ] +} \ No newline at end of file diff --git a/testdata/dump/issue_80_index_name_quote/pgdump.sql b/testdata/dump/issue_80_index_name_quote/pgdump.sql new file mode 100644 index 00000000..77dbd1c0 --- /dev/null +++ b/testdata/dump/issue_80_index_name_quote/pgdump.sql @@ -0,0 +1,140 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 17.5 (Debian 17.5-1.pgdg120+1) +-- Dumped by pg_dump version 17.5 (Homebrew) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: products; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.products ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name text NOT NULL, + price numeric(10,2), + category text +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + email text NOT NULL, + username text NOT NULL, + created_at timestamp without time zone DEFAULT now(), + status text, + "position" integer, + department text +); + + +-- +-- Name: products products_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.products + ADD CONSTRAINT products_pkey PRIMARY KEY (id); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: UPPER name search; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "UPPER name search" ON public.products USING btree (upper(name)); + + +-- +-- Name: UserDepartmentIndex; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "UserDepartmentIndex" ON public.users USING btree (department); + + +-- +-- Name: active users index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "active users index" ON public.users USING btree (status) WHERE (status = 'active'::text); + + +-- +-- Name: email+username combo; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX "email+username combo" ON public.users USING btree (email, username); + + +-- +-- Name: order; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "order" ON public.products USING btree (price DESC); + + +-- +-- Name: products_category_idx_v2; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX products_category_idx_v2 ON public.products USING btree (category); + + +-- +-- Name: user email index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX "user email index" ON public.users USING btree (email); + + +-- +-- Name: user-status-index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "user-status-index" ON public.users USING btree (status); + + +-- +-- Name: users.position.idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX "users.position.idx" ON public.users USING btree ("position"); + + +-- +-- Name: users_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_created_at_idx ON public.users USING btree (created_at); + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/testdata/dump/issue_80_index_name_quote/pgschema.sql b/testdata/dump/issue_80_index_name_quote/pgschema.sql new file mode 100644 index 00000000..dfc03aed --- /dev/null +++ b/testdata/dump/issue_80_index_name_quote/pgschema.sql @@ -0,0 +1,93 @@ +-- +-- pgschema database dump +-- + +-- Dumped from database version PostgreSQL 17.5 +-- Dumped by pgschema version 1.3.0 + + +-- +-- Name: products; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS products ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + name text NOT NULL, + price numeric(10,2), + category text +); + +-- +-- Name: UPPER name search; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "UPPER name search" ON products (upper(name)); + +-- +-- Name: order; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "order" ON products (price DESC); + +-- +-- Name: products_category_idx_v2; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS products_category_idx_v2 ON products (category); + +-- +-- Name: users; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS users ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + email text NOT NULL, + username text NOT NULL, + created_at timestamp DEFAULT now(), + status text, + position integer, + department text +); + +-- +-- Name: UserDepartmentIndex; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "UserDepartmentIndex" ON users (department); + +-- +-- Name: active users index; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "active users index" ON users (status) WHERE (status = 'active'::text); + +-- +-- Name: email+username combo; Type: INDEX; Schema: -; Owner: - +-- + +CREATE UNIQUE INDEX IF NOT EXISTS "email+username combo" ON users (email, username); + +-- +-- Name: user email index; Type: INDEX; Schema: -; Owner: - +-- + +CREATE UNIQUE INDEX IF NOT EXISTS "user email index" ON users (email); + +-- +-- Name: user-status-index; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "user-status-index" ON users (status); + +-- +-- Name: idx; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS "users.position.idx" ON users (position); + +-- +-- Name: users_created_at_idx; Type: INDEX; Schema: -; Owner: - +-- + +CREATE INDEX IF NOT EXISTS users_created_at_idx ON users (created_at); + diff --git a/testdata/dump/issue_80_index_name_quote/raw.sql b/testdata/dump/issue_80_index_name_quote/raw.sql new file mode 100644 index 00000000..870002a9 --- /dev/null +++ b/testdata/dump/issue_80_index_name_quote/raw.sql @@ -0,0 +1,84 @@ +-- +-- Test case for GitHub issue #80: Index name quoting +-- +-- This demonstrates various scenarios where index names need quoting: +-- 1. Index names with spaces +-- 2. Index names with special characters (hyphens, dots, etc.) +-- 3. Index names with mixed case that requires quoting +-- 4. Normal index names that don't require quoting (control case) +-- + +-- +-- Test table for index quoting scenarios +-- +CREATE TABLE users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + email text NOT NULL, + username text NOT NULL, + created_at timestamp DEFAULT now(), + status text, + position integer, + department text, + CONSTRAINT users_pkey PRIMARY KEY (id) +); + +-- +-- Case 1: Index name with spaces (from the original issue) +-- +CREATE UNIQUE INDEX "user email index" ON users (email); + +-- +-- Case 2: Index name with hyphens (special character) +-- +CREATE INDEX "user-status-index" ON users (status); + +-- +-- Case 3: Index name with dots +-- +CREATE INDEX "users.position.idx" ON users (position); + +-- +-- Case 4: Mixed case requiring quotes +-- +CREATE INDEX "UserDepartmentIndex" ON users (department); + +-- +-- Case 5: Normal index name (no quotes needed) +-- +CREATE INDEX users_created_at_idx ON users (created_at); + +-- +-- Case 6: Partial index with quoted name and spaces +-- +CREATE INDEX "active users index" ON users (status) WHERE status = 'active'; + +-- +-- Case 7: Multi-column index with special name +-- +CREATE UNIQUE INDEX "email+username combo" ON users (email, username); + +-- +-- Additional test table for more complex scenarios +-- +CREATE TABLE products ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name text NOT NULL, + price numeric(10,2), + category text, + CONSTRAINT products_pkey PRIMARY KEY (id) +); + +-- +-- Case 8: Functional index with quoted name +-- +CREATE INDEX "UPPER name search" ON products (upper(name)); + +-- +-- Case 9: Index with numbers and underscores (doesn't need quotes) +-- +CREATE INDEX products_category_idx_v2 ON products (category); + +-- +-- Case 10: Index name that looks like a keyword (needs quotes) +-- +CREATE INDEX "order" ON products (price DESC); \ No newline at end of file