db backup-restore with fk
All checks were successful
Deploy Plattform-Core / test (push) Successful in 55s
Deploy Plattform-Core / deploy (push) Successful in 6s

This commit is contained in:
ValueOn AG 2026-05-25 14:34:02 +02:00
parent 31955751fb
commit c2443a7781

View file

@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
_EXPORT_FORMAT_VERSION = "1.0"
_SYSTEM_TABLE = "_system"
_EXCLUDED_TABLES: Dict[str, Set[str]] = {
"poweron_app": {"Token", "AuthEvent"},
}
# ---------------------------------------------------------------------------
# Instance label
@ -114,10 +118,14 @@ def _exportDatabases(databases: List[str]) -> dict:
def _exportSingleDb(dbName: str) -> dict:
conn = _getConnection(dbName)
excluded = _EXCLUDED_TABLES.get(dbName, set())
try:
tables = _listTables(conn)
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
for tbl in tables:
if tbl in excluded:
logger.info("Export: skipping excluded table %s.%s", dbName, tbl)
continue
rows = _readTableRows(conn, tbl)
dbPayload["tables"][tbl] = rows
dbPayload["summary"][tbl] = {"recordCount": len(rows)}
@ -627,9 +635,66 @@ def _createTableFromExport(conn, tableName: str, rows: List[dict]) -> None:
logger.info("Created table %s with %d columns", tableName, len(allKeys))
def _getTableImportOrder(conn, tableNames: List[str]) -> List[str]:
"""Sort tables by FK dependencies (parents first) using topological sort.
Queries ``information_schema`` for FK relationships, builds a dependency
graph, and returns the tables in an order that satisfies referential
integrity: parent tables before child tables.
"""
tableSet = set(tableNames)
with conn.cursor() as cur:
cur.execute("""
SELECT DISTINCT
tc.table_name AS child_table,
ccu.table_name AS parent_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name != ccu.table_name
""")
fks = cur.fetchall()
deps: Dict[str, Set[str]] = {t: set() for t in tableNames}
for fk in fks:
child = fk["child_table"]
parent = fk["parent_table"]
if child in tableSet and parent in tableSet:
deps[child].add(parent)
inDegree = {t: len(deps[t]) for t in tableNames}
queue = sorted(t for t in tableNames if inDegree[t] == 0)
ordered: List[str] = []
while queue:
node = queue.pop(0)
ordered.append(node)
for t in tableNames:
if node in deps[t]:
deps[t].discard(node)
inDegree[t] -= 1
if inDegree[t] == 0:
queue.append(t)
queue.sort()
remaining = [t for t in tableNames if t not in set(ordered)]
if remaining:
logger.warning("FK cycle detected, appending without order guarantee: %s", remaining)
ordered.extend(sorted(remaining))
return ordered
def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[str]) -> dict:
"""Import a single database from the (already remapped) payload.
Tables are sorted by FK dependencies: parent tables are inserted first,
child tables are deleted first (reverse order) in replace mode.
Returns ``{database, tables: {tableName: insertedCount}, recordCount, warnings}``.
"""
if mode not in ("replace", "merge"):
@ -656,6 +721,7 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st
tables = dbData.get("tables", {})
warnings: List[str] = []
dbResult: Dict[str, int] = {}
excluded = _EXCLUDED_TABLES.get(dbName, set())
if dbCreated:
warnings.append(f"Datenbank '{dbName}' wurde neu erstellt")
@ -665,15 +731,37 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st
conn.autocommit = False
existingTables = set(_listTables(conn))
# Pre-create missing tables so FK ordering can discover them
for tableName, rows in tables.items():
if not isinstance(rows, list):
if tableName in excluded or not isinstance(rows, list):
continue
if tableName not in existingTables:
_createTableFromExport(conn, tableName, rows)
conn.commit()
conn.autocommit = False
existingTables.add(tableName)
# Build importable table list and sort by FK dependencies
importable = [t for t in tables
if t not in excluded
and isinstance(tables.get(t), list)
and t in existingTables]
importOrder = _getTableImportOrder(conn, importable)
logger.info("Import order for %s: %s", dbName, importOrder)
for tableName in tables:
if tableName in excluded and isinstance(tables.get(tableName), list):
warnings.append(f"Table '{dbName}.{tableName}' excluded (security/transient)")
# Phase 1 (replace only): DELETE children first (reverse topological order)
if mode == "replace":
for tableName in reversed(importOrder):
_deleteNonProtected(conn, tableName, protectedIdSet)
# Phase 2: INSERT parents first (topological order)
for tableName in importOrder:
rows = tables[tableName]
physicalCols = _getPhysicalColumns(conn, tableName)
if not physicalCols:
continue
@ -686,9 +774,6 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st
continue
filteredRows.append(row)
if mode == "replace":
_deleteNonProtected(conn, tableName, protectedIdSet)
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
dbResult[tableName] = insertedCount