diff --git a/src/baudolo/restore/db/postgres.py b/src/baudolo/restore/db/postgres.py index b199e21..e186dcf 100644 --- a/src/baudolo/restore/db/postgres.py +++ b/src/baudolo/restore/db/postgres.py @@ -99,6 +99,16 @@ SELECT format('DROP %s IF EXISTS public.%s CASCADE', obj.type, obj.name) FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace WHERE n.nspname = 'public' AND pg_get_userbyid(col.collowner) = current_user + UNION ALL + SELECT format('%I', ts.cfgname) AS name, 'TEXT SEARCH CONFIGURATION' AS type + FROM pg_ts_config ts JOIN pg_namespace n ON n.oid = ts.cfgnamespace + WHERE n.nspname = 'public' + AND pg_get_userbyid(ts.cfgowner) = current_user + UNION ALL + SELECT format('%I', d.dictname) AS name, 'TEXT SEARCH DICTIONARY' AS type + FROM pg_ts_dict d JOIN pg_namespace n ON n.oid = d.dictnamespace + WHERE n.nspname = 'public' + AND pg_get_userbyid(d.dictowner) = current_user ) obj UNION ALL SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', n.nspname) diff --git a/tests/e2e/test_e2e_postgres_empty_drop_hard.py b/tests/e2e/test_e2e_postgres_empty_drop_hard.py index 8b1e9ec..4959e38 100644 --- a/tests/e2e/test_e2e_postgres_empty_drop_hard.py +++ b/tests/e2e/test_e2e_postgres_empty_drop_hard.py @@ -21,6 +21,9 @@ from .helpers import ( # --empty runs against the still-populated DB (no wipe), so the pre-clean must # drop discourse_functions too or the dump's CREATE SCHEMA aborts the replay # under ON_ERROR_STOP; the old public-only DROP left it and broke discourse. +# The f()/f(int) pair reproduces discourse's overload abort ("function name +# is not unique") and english_stem_nostop reproduces taiga's text search +# dictionary abort (duplicate pg_ts_dict_dictname_index). SCENARIO_SQL = ( "CREATE SCHEMA discourse_functions;" "CREATE TABLE discourse_functions.helper (id int);" @@ -31,7 +34,13 @@ SCENARIO_SQL = ( "CREATE SEQUENCE public.s;" "CREATE TYPE public.mood AS ENUM ('ok', 'bad');" "CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';" + "CREATE FUNCTION public.f(i int) RETURNS int LANGUAGE sql AS 'SELECT i';" "CREATE COLLATION public.c (locale = 'C');" + "CREATE TEXT SEARCH DICTIONARY public.english_stem_nostop" + " (Template = snowball, Language = english);" + "CREATE TEXT SEARCH CONFIGURATION public.english_nostop (COPY = english);" + "ALTER TEXT SEARCH CONFIGURATION public.english_nostop" + " ALTER MAPPING FOR asciiword WITH public.english_stem_nostop;" ) @@ -152,6 +161,26 @@ class TestE2EPostgresEmptyDropHard(unittest.TestCase): "1", ) + def test_overloaded_functions_restored_once_each(self) -> None: + self.assertEqual( + self._scalar("SELECT count(*) FROM pg_proc WHERE proname='f';"), "2" + ) + self.assertEqual(self._scalar("SELECT public.f(41) + public.f();"), "42") + + def test_text_search_dictionary_restored_once(self) -> None: + self.assertEqual( + self._scalar( + "SELECT count(*) FROM pg_ts_dict WHERE dictname='english_stem_nostop';" + ), + "1", + ) + self.assertEqual( + self._scalar( + "SELECT count(*) FROM pg_ts_config WHERE cfgname='english_nostop';" + ), + "1", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/restore/test_postgres_empty_drop.py b/tests/unit/restore/test_postgres_empty_drop.py deleted file mode 100644 index eb73301..0000000 --- a/tests/unit/restore/test_postgres_empty_drop.py +++ /dev/null @@ -1,57 +0,0 @@ -"""The --empty pre-clean drop must cover every object class a dump can -re-CREATE ahead of tables; a missed class aborts the replay under -ON_ERROR_STOP (OpenProject's ICU collation public.versions_name did).""" - -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -from baudolo.restore.db import postgres - - -class TestPostgresEmptyDrop(unittest.TestCase): - def _drop_sql(self) -> str: - with tempfile.NamedTemporaryFile(suffix=".sql") as fh: - Path(fh.name).write_bytes(b"SELECT 1;\n") - with mock.patch.object(postgres, "docker_exec") as run: - postgres.restore_postgres_sql( - container="c", - db_name="db", - user="u", - password="p", - sql_path=fh.name, - empty=True, - ) - first_call = run.call_args_list[0] - return first_call.kwargs["stdin"].decode() - - def test_drop_covers_collations(self) -> None: - sql = self._drop_sql() - self.assertIn("pg_collation", sql) - self.assertIn("'COLLATION' AS type", sql) - self.assertIn("pg_get_userbyid(col.collowner) = current_user", sql) - - def test_drop_still_covers_the_other_classes(self) -> None: - sql = self._drop_sql() - for marker in ("pg_class", "pg_proc", "'SEQUENCE' AS type", "'TYPE' AS type"): - self.assertIn(marker, sql) - self.assertIn("DROP %s IF EXISTS public.%s CASCADE", sql) - - def test_functions_drop_with_identity_signature(self) -> None: - """Overloaded functions share a proname; a signature-less DROP aborts - with "function name is not unique", so the function branch must emit - name(identity args) while every other branch stays %I-quoted.""" - sql = self._drop_sql() - self.assertIn("pg_get_function_identity_arguments(p.oid)", sql) - self.assertIn("format('%I(%s)', p.proname", sql) - for branch in ( - "format('%I', c.relname)", - "format('%I', t.typname)", - "format('%I', col.collname)", - ): - self.assertIn(branch, sql) - - -if __name__ == "__main__": - unittest.main()