| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- """Export the local DMS demo database rows and referenced storage files.
- The connection URL is read from an existing dotenv file and is never written
- to the export. The resulting SQL is data-only and targets an already migrated
- empty DMS database.
- """
- from __future__ import annotations
- import argparse
- import hashlib
- import json
- import shutil
- from datetime import date, datetime, time
- from decimal import Decimal
- from pathlib import Path
- from typing import Any
- from dotenv import dotenv_values
- from sqlalchemy import MetaData, Table, create_engine, select
- TABLES = (
- "sys_organization",
- "sys_user",
- "doc_category",
- "doc_document",
- "doc_attachment_binding",
- "doc_permission",
- "sys_audit_log",
- )
- def sql_literal(value: Any) -> str:
- if value is None:
- return "NULL"
- if isinstance(value, bool):
- return "1" if value else "0"
- if isinstance(value, (int, Decimal)):
- return str(value)
- if isinstance(value, float):
- return repr(value)
- if isinstance(value, (datetime, date, time)):
- value = value.isoformat(sep=" ") if isinstance(value, datetime) else value.isoformat()
- if isinstance(value, (dict, list)):
- value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
- if isinstance(value, bytes):
- return "X'" + value.hex() + "'"
- escaped = str(value).replace("\\", "\\\\").replace("'", "''")
- return "'" + escaped + "'"
- def sha256(path: Path) -> str:
- digest = hashlib.sha256()
- with path.open("rb") as stream:
- for chunk in iter(lambda: stream.read(1024 * 1024), b""):
- digest.update(chunk)
- return digest.hexdigest()
- def export(env_file: Path, source_storage: Path, output: Path) -> None:
- config = dotenv_values(env_file)
- database_url = (config.get("DMS_DATABASE_URL") or "").strip()
- if not database_url:
- raise RuntimeError("DMS_DATABASE_URL is missing from the source dotenv file")
- output.mkdir(parents=True, exist_ok=True)
- storage_output = output / "storage"
- storage_output.mkdir(exist_ok=True)
- engine = create_engine(database_url, pool_pre_ping=True)
- metadata = MetaData()
- reflected = {
- name: Table(name, metadata, autoload_with=engine)
- for name in TABLES
- }
- rows_by_table: dict[str, list[dict[str, Any]]] = {}
- with engine.connect() as connection:
- for name in TABLES:
- rows_by_table[name] = [dict(row) for row in connection.execute(select(reflected[name])).mappings()]
- engine.dispose()
- document_paths: dict[str, dict[str, Any]] = {}
- for row in rows_by_table["doc_document"]:
- relative = str(row.get("file_relative_path") or "").strip().replace("\\", "/")
- if not relative:
- continue
- if relative.startswith("/") or ".." in Path(relative).parts:
- raise RuntimeError(f"Unsafe storage path in database: {relative}")
- source = source_storage / Path(relative)
- if not source.is_file():
- raise RuntimeError(f"Referenced storage file is missing: {relative}")
- expected_size = row.get("file_size")
- if expected_size is not None and source.stat().st_size != int(expected_size):
- raise RuntimeError(f"Referenced storage file has unexpected size: {relative}")
- actual_hash = sha256(source)
- expected_hash = str(row.get("file_hash") or "").lower()
- if expected_hash and actual_hash != expected_hash:
- raise RuntimeError(f"Referenced storage file has unexpected SHA-256: {relative}")
- destination = storage_output / Path(relative)
- destination.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(source, destination)
- document_paths[relative] = {
- "size": source.stat().st_size,
- "sha256": actual_hash,
- }
- sql_path = output / "full_demo_data.sql"
- with sql_path.open("w", encoding="utf-8", newline="\n") as sql:
- sql.write("-- DMS full demo data export (data only; no schema or secrets)\n")
- sql.write("SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\nSTART TRANSACTION;\n")
- for name in reversed(TABLES):
- sql.write(f"DELETE FROM `{name}`;\n")
- for name in TABLES:
- table = reflected[name]
- # MySQL generated columns (for example active_marker) must not be
- # supplied explicitly in INSERT statements.
- columns = [
- column.name
- for column in table.columns
- if column.computed is None
- ]
- quoted_columns = ", ".join(f"`{column}`" for column in columns)
- for row in rows_by_table[name]:
- values = ", ".join(sql_literal(row.get(column)) for column in columns)
- sql.write(f"INSERT INTO `{name}` ({quoted_columns}) VALUES ({values});\n")
- sql.write("COMMIT;\nSET FOREIGN_KEY_CHECKS=1;\n")
- manifest = {
- "format": "DMS full demo data v1",
- "database_tables": {name: len(rows_by_table[name]) for name in TABLES},
- "storage_file_count": len(document_paths),
- "storage_total_bytes": sum(item["size"] for item in document_paths.values()),
- "storage_files": document_paths,
- "sql_sha256": sha256(sql_path),
- }
- (output / "manifest.json").write_text(
- json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
- encoding="utf-8",
- )
- print(json.dumps(manifest, ensure_ascii=False, indent=2))
- def main() -> None:
- parser = argparse.ArgumentParser()
- parser.add_argument("--env-file", type=Path, required=True)
- parser.add_argument("--storage", type=Path, required=True)
- parser.add_argument("--output", type=Path, required=True)
- arguments = parser.parse_args()
- export(arguments.env_file.resolve(), arguments.storage.resolve(), arguments.output.resolve())
- if __name__ == "__main__":
- main()
|