db backup-restore with fk
This commit is contained in:
parent
31955751fb
commit
c2443a7781
1 changed files with 90 additions and 5 deletions
|
|
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
||||||
_EXPORT_FORMAT_VERSION = "1.0"
|
_EXPORT_FORMAT_VERSION = "1.0"
|
||||||
_SYSTEM_TABLE = "_system"
|
_SYSTEM_TABLE = "_system"
|
||||||
|
|
||||||
|
_EXCLUDED_TABLES: Dict[str, Set[str]] = {
|
||||||
|
"poweron_app": {"Token", "AuthEvent"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Instance label
|
# Instance label
|
||||||
|
|
@ -114,10 +118,14 @@ def _exportDatabases(databases: List[str]) -> dict:
|
||||||
|
|
||||||
def _exportSingleDb(dbName: str) -> dict:
|
def _exportSingleDb(dbName: str) -> dict:
|
||||||
conn = _getConnection(dbName)
|
conn = _getConnection(dbName)
|
||||||
|
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||||
try:
|
try:
|
||||||
tables = _listTables(conn)
|
tables = _listTables(conn)
|
||||||
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
|
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
|
||||||
for tbl in tables:
|
for tbl in tables:
|
||||||
|
if tbl in excluded:
|
||||||
|
logger.info("Export: skipping excluded table %s.%s", dbName, tbl)
|
||||||
|
continue
|
||||||
rows = _readTableRows(conn, tbl)
|
rows = _readTableRows(conn, tbl)
|
||||||
dbPayload["tables"][tbl] = rows
|
dbPayload["tables"][tbl] = rows
|
||||||
dbPayload["summary"][tbl] = {"recordCount": len(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))
|
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:
|
def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[str]) -> dict:
|
||||||
"""Import a single database from the (already remapped) payload.
|
"""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}``.
|
Returns ``{database, tables: {tableName: insertedCount}, recordCount, warnings}``.
|
||||||
"""
|
"""
|
||||||
if mode not in ("replace", "merge"):
|
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", {})
|
tables = dbData.get("tables", {})
|
||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
dbResult: Dict[str, int] = {}
|
dbResult: Dict[str, int] = {}
|
||||||
|
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||||
|
|
||||||
if dbCreated:
|
if dbCreated:
|
||||||
warnings.append(f"Datenbank '{dbName}' wurde neu erstellt")
|
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
|
conn.autocommit = False
|
||||||
existingTables = set(_listTables(conn))
|
existingTables = set(_listTables(conn))
|
||||||
|
|
||||||
|
# Pre-create missing tables so FK ordering can discover them
|
||||||
for tableName, rows in tables.items():
|
for tableName, rows in tables.items():
|
||||||
if not isinstance(rows, list):
|
if tableName in excluded or not isinstance(rows, list):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if tableName not in existingTables:
|
if tableName not in existingTables:
|
||||||
_createTableFromExport(conn, tableName, rows)
|
_createTableFromExport(conn, tableName, rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.autocommit = False
|
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)
|
physicalCols = _getPhysicalColumns(conn, tableName)
|
||||||
if not physicalCols:
|
if not physicalCols:
|
||||||
continue
|
continue
|
||||||
|
|
@ -686,9 +774,6 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st
|
||||||
continue
|
continue
|
||||||
filteredRows.append(row)
|
filteredRows.append(row)
|
||||||
|
|
||||||
if mode == "replace":
|
|
||||||
_deleteNonProtected(conn, tableName, protectedIdSet)
|
|
||||||
|
|
||||||
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
||||||
dbResult[tableName] = insertedCount
|
dbResult[tableName] = insertedCount
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue