Skip to content

Commit d99e127

Browse files
authored
dump/restore revival (#155)
1 parent 99730ee commit d99e127

File tree

5 files changed

+168
-7
lines changed

5 files changed

+168
-7
lines changed

.github/workflows/tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
exit 1
8080
fi
8181
82+
- name: Run dump and restore tests
83+
shell: bash
84+
run: ./test/test_dump_restore.sh
85+
8286
# - name: Run migrations tests
8387
# shell: bash
8488
# run: ./test/test_pum.sh

.github/workflows/windows-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ jobs:
7373
- name: Run base tests
7474
run: nose2 -v
7575

76+
- name: Run dump and restore tests
77+
shell: bash
78+
run: ./test/test_dump_restore.sh
79+
7680
# - name: Run migrations tests
7781
# shell: bash
7882
# run: ./test/test_pum.sh

pum/checker.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass, field
22
from datetime import datetime
33
from enum import Enum
4+
import re
45

56
import psycopg
67

@@ -339,9 +340,23 @@ def check_constraints(self):
339340
ORDER BY n.nspname, cl.relname, c.conname
340341
"""
341342

343+
# Normalization function for constraint records
344+
def normalize_constraint_record(record_dict, col_names):
345+
"""Normalize constraint definitions in a record."""
346+
normalized = record_dict.copy()
347+
if "constraint_definition" in normalized and normalized["constraint_definition"]:
348+
normalized["constraint_definition"] = self.__normalize_constraint_definition(
349+
normalized["constraint_definition"]
350+
)
351+
return normalized
352+
342353
# Execute both queries and combine results
343-
passed_keys, diffs_keys = self.__check_equals(key_query)
344-
passed_checks, diffs_checks = self.__check_equals(check_query)
354+
passed_keys, diffs_keys = self.__check_equals(
355+
key_query, normalize_func=normalize_constraint_record
356+
)
357+
passed_checks, diffs_checks = self.__check_equals(
358+
check_query, normalize_func=normalize_constraint_record
359+
)
345360

346361
return (passed_keys and passed_checks, diffs_keys + diffs_checks)
347362

@@ -527,11 +542,57 @@ def check_rules(self):
527542

528543
return self.__check_equals(query)
529544

530-
def __check_equals(self, query) -> tuple[bool, list[DifferenceItem]]:
545+
@staticmethod
546+
def __normalize_constraint_definition(definition: str) -> str:
547+
"""Normalize a constraint definition for comparison.
548+
549+
PostgreSQL may represent functionally equivalent constraints differently,
550+
especially after dump/restore operations. This function normalizes common
551+
variations to enable accurate comparison.
552+
553+
Args:
554+
definition: The constraint definition string from pg_get_constraintdef()
555+
556+
Returns:
557+
Normalized constraint definition
558+
"""
559+
if not definition:
560+
return definition
561+
562+
# Normalize different ARRAY representations:
563+
# Before: (ARRAY['a'::type, 'b'::type])::type[] OR ARRAY[('a'::type)::text, ...]
564+
# After: Canonical form based on sorted elements
565+
566+
# Strategy: Extract the constraint type and key values, ignoring formatting details
567+
# For ANY/ALL with arrays, extract just the operator and the array values
568+
569+
# Remove extra parentheses around ARRAY expressions
570+
# (ARRAY[...])::type[] -> ARRAY[...]::type[]
571+
definition = re.sub(r"\(\(ARRAY\[(.*?)\]\)::(.*?)\[\]\)", r"ARRAY[\1]::\2[]", definition)
572+
573+
# Also remove parentheses without cast: (ARRAY[...]) -> ARRAY[...]
574+
definition = re.sub(r"\(ARRAY\[([^\]]+)\]\)", r"ARRAY[\1]", definition)
575+
576+
# Normalize array element casts: ('value'::type1)::type2 -> 'value'::type1
577+
# This handles the case where elements are double-cast
578+
definition = re.sub(r"\('([^']+)'::([^)]+)\)::(\w+)", r"'\1'::\2", definition)
579+
580+
# Remove trailing array cast that may be present or absent: ::text[] or ::character varying[]
581+
# This is safe because the type information is already in each array element
582+
definition = re.sub(r"::(?:text|character varying)\[\]", "", definition)
583+
584+
# Remove extra whitespace and normalize spacing
585+
definition = re.sub(r"\s+", " ", definition).strip()
586+
587+
return definition
588+
589+
def __check_equals(self, query, normalize_func=None) -> tuple[bool, list[DifferenceItem]]:
531590
"""Check if the query results on the two databases are equals.
532591
533592
Args:
534593
query: The SQL query to execute on both databases.
594+
normalize_func: Optional function to normalize specific fields in records.
595+
Should accept (dict, col_names) and return normalized dict.
535596
536597
Returns:
537598
tuple: A tuple containing:
@@ -554,6 +615,14 @@ def __check_equals(self, query) -> tuple[bool, list[DifferenceItem]]:
554615
structured1 = [dict(zip(col_names, record)) for record in records1]
555616
structured2 = [dict(zip(col_names, record)) for record in records2]
556617

618+
# Apply normalization if provided
619+
if normalize_func:
620+
structured1 = [normalize_func(r, col_names) for r in structured1]
621+
structured2 = [normalize_func(r, col_names) for r in structured2]
622+
# Recreate records from normalized structured data
623+
records1 = [tuple(r[col] for col in col_names) for r in structured1]
624+
records2 = [tuple(r[col] for col in col_names) for r in structured2]
625+
557626
# Create sets for comparison
558627
set1 = {str(tuple(r)) for r in records1}
559628
set2 = {str(tuple(r)) for r in records2}

pum/cli.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .upgrader import Upgrader
1818
from .parameter import ParameterType
1919
from .schema_migrations import SchemaMigrations
20-
from .dumper import DumpFormat
20+
from .dumper import DumpFormat, Dumper
2121

2222

2323
def setup_logging(verbosity: int = 0):
@@ -408,7 +408,6 @@ def cli() -> int: # noqa: PLR0912
408408
raise ValueError(f"Unsupported parameter type for {p[0]}: {param.type}")
409409
logger.debug(f"Parameters: {parameters}")
410410

411-
pum = Pum(args.pg_connection, config)
412411
exit_code = 0
413412

414413
if args.command == "info":
@@ -465,9 +464,21 @@ def cli() -> int: # noqa: PLR0912
465464
logger.error(f"Unknown action: {args.action}")
466465
exit_code = 1
467466
elif args.command == "dump":
468-
pass
467+
dumper = Dumper(args.pg_connection, args.file)
468+
dumper.pg_dump(
469+
exclude_schema=args.exclude_schema or [],
470+
format=args.format,
471+
)
472+
logger.info(f"Database dumped to {args.file}")
469473
elif args.command == "restore":
470-
pum.run_restore(args.pg_connection, args.file, args.x, args.exclude_schema)
474+
dumper = Dumper(args.pg_connection, args.file)
475+
try:
476+
dumper.pg_restore(exclude_schema=args.exclude_schema or [])
477+
logger.info(f"Database restored from {args.file}")
478+
except Exception as e:
479+
if not args.x:
480+
raise
481+
logger.warning(f"Restore completed with errors (ignored): {e}")
471482
elif args.command == "baseline":
472483
sm = SchemaMigrations(config=config)
473484
if not sm.exists(connection=conn):

test/test_dump_restore.sh

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env bash
2+
3+
# Script to test the PUM dump and restore CLI commands
4+
# This script sets up a test database, dumps it, restores to another database,
5+
# and checks that they are identical
6+
7+
set -e
8+
9+
# Configuration
10+
PG_SERVICE1="pum_test"
11+
PG_SERVICE2="pum_test_2"
12+
TEST_DIR="test/data/checker_test"
13+
DUMP_FILE="/tmp/pum_test_dump.backup"
14+
15+
echo "🔧 Testing PUM dump and restore commands..."
16+
echo ""
17+
18+
# Clean databases
19+
echo "🧹 Cleaning test databases..."
20+
psql service=$PG_SERVICE1 -c "DROP SCHEMA IF EXISTS pum_test_checker CASCADE; DROP TABLE IF EXISTS public.pum_migrations;" 2>/dev/null || true
21+
psql service=$PG_SERVICE2 -c "DROP SCHEMA IF EXISTS pum_test_checker CASCADE; DROP TABLE IF EXISTS public.pum_migrations;" 2>/dev/null || true
22+
23+
# Install version 1.1.0 on first database
24+
echo "📦 Installing version 1.1.0 on $PG_SERVICE1..."
25+
pum -p $PG_SERVICE1 -d $TEST_DIR install
26+
echo "✅ Installation complete"
27+
echo ""
28+
29+
# Dump the first database
30+
echo "💾 Dumping $PG_SERVICE1 to $DUMP_FILE..."
31+
pum -p $PG_SERVICE1 -d $TEST_DIR dump -f custom -N public "$DUMP_FILE"
32+
echo "✅ Dump complete"
33+
echo ""
34+
35+
# Check that dump file exists and has content
36+
if [ ! -f "$DUMP_FILE" ]; then
37+
echo "❌ Dump file not created!"
38+
exit 1
39+
fi
40+
41+
FILE_SIZE=$(stat -f%z "$DUMP_FILE" 2>/dev/null || stat -c%s "$DUMP_FILE" 2>/dev/null)
42+
echo "📊 Dump file size: $FILE_SIZE bytes"
43+
echo ""
44+
45+
# Restore to the second database
46+
echo "📥 Restoring dump to $PG_SERVICE2..."
47+
pum -p $PG_SERVICE2 -d $TEST_DIR restore -N public "$DUMP_FILE"
48+
echo "✅ Restore complete"
49+
echo ""
50+
51+
# Run checker to verify databases are identical
52+
echo "🔍 Running checker to verify databases are identical..."
53+
set +e # Don't exit on error for this command
54+
55+
pum -p $PG_SERVICE1 -d $TEST_DIR check $PG_SERVICE2 -N public
56+
CHECKER_EXIT=$?
57+
58+
set -e
59+
60+
if [ $CHECKER_EXIT -eq 0 ]; then
61+
echo "✅ SUCCESS! Databases are identical after dump and restore."
62+
# Clean up dump file
63+
rm -f "$DUMP_FILE"
64+
exit 0
65+
elif [ $CHECKER_EXIT -eq 1 ]; then
66+
echo "❌ FAIL! Differences found between databases after dump and restore."
67+
# Keep dump file for inspection
68+
echo "⚠️ Dump file kept at: $DUMP_FILE"
69+
exit 1
70+
else
71+
echo "❌ Checker failed with exit code $CHECKER_EXIT"
72+
exit $CHECKER_EXIT
73+
fi

0 commit comments

Comments
 (0)