export_full_demo_data.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. """Export the local DMS demo database rows and referenced storage files.
  2. The connection URL is read from an existing dotenv file and is never written
  3. to the export. The resulting SQL is data-only and targets an already migrated
  4. empty DMS database.
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import hashlib
  9. import json
  10. import shutil
  11. from datetime import date, datetime, time
  12. from decimal import Decimal
  13. from pathlib import Path
  14. from typing import Any
  15. from dotenv import dotenv_values
  16. from sqlalchemy import MetaData, Table, create_engine, select
  17. TABLES = (
  18. "sys_organization",
  19. "sys_user",
  20. "doc_category",
  21. "doc_document",
  22. "doc_attachment_binding",
  23. "doc_permission",
  24. "sys_audit_log",
  25. )
  26. def sql_literal(value: Any) -> str:
  27. if value is None:
  28. return "NULL"
  29. if isinstance(value, bool):
  30. return "1" if value else "0"
  31. if isinstance(value, (int, Decimal)):
  32. return str(value)
  33. if isinstance(value, float):
  34. return repr(value)
  35. if isinstance(value, (datetime, date, time)):
  36. value = value.isoformat(sep=" ") if isinstance(value, datetime) else value.isoformat()
  37. if isinstance(value, (dict, list)):
  38. value = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
  39. if isinstance(value, bytes):
  40. return "X'" + value.hex() + "'"
  41. escaped = str(value).replace("\\", "\\\\").replace("'", "''")
  42. return "'" + escaped + "'"
  43. def sha256(path: Path) -> str:
  44. digest = hashlib.sha256()
  45. with path.open("rb") as stream:
  46. for chunk in iter(lambda: stream.read(1024 * 1024), b""):
  47. digest.update(chunk)
  48. return digest.hexdigest()
  49. def export(env_file: Path, source_storage: Path, output: Path) -> None:
  50. config = dotenv_values(env_file)
  51. database_url = (config.get("DMS_DATABASE_URL") or "").strip()
  52. if not database_url:
  53. raise RuntimeError("DMS_DATABASE_URL is missing from the source dotenv file")
  54. output.mkdir(parents=True, exist_ok=True)
  55. storage_output = output / "storage"
  56. storage_output.mkdir(exist_ok=True)
  57. engine = create_engine(database_url, pool_pre_ping=True)
  58. metadata = MetaData()
  59. reflected = {
  60. name: Table(name, metadata, autoload_with=engine)
  61. for name in TABLES
  62. }
  63. rows_by_table: dict[str, list[dict[str, Any]]] = {}
  64. with engine.connect() as connection:
  65. for name in TABLES:
  66. rows_by_table[name] = [dict(row) for row in connection.execute(select(reflected[name])).mappings()]
  67. engine.dispose()
  68. document_paths: dict[str, dict[str, Any]] = {}
  69. for row in rows_by_table["doc_document"]:
  70. relative = str(row.get("file_relative_path") or "").strip().replace("\\", "/")
  71. if not relative:
  72. continue
  73. if relative.startswith("/") or ".." in Path(relative).parts:
  74. raise RuntimeError(f"Unsafe storage path in database: {relative}")
  75. source = source_storage / Path(relative)
  76. if not source.is_file():
  77. raise RuntimeError(f"Referenced storage file is missing: {relative}")
  78. expected_size = row.get("file_size")
  79. if expected_size is not None and source.stat().st_size != int(expected_size):
  80. raise RuntimeError(f"Referenced storage file has unexpected size: {relative}")
  81. actual_hash = sha256(source)
  82. expected_hash = str(row.get("file_hash") or "").lower()
  83. if expected_hash and actual_hash != expected_hash:
  84. raise RuntimeError(f"Referenced storage file has unexpected SHA-256: {relative}")
  85. destination = storage_output / Path(relative)
  86. destination.parent.mkdir(parents=True, exist_ok=True)
  87. shutil.copy2(source, destination)
  88. document_paths[relative] = {
  89. "size": source.stat().st_size,
  90. "sha256": actual_hash,
  91. }
  92. sql_path = output / "full_demo_data.sql"
  93. with sql_path.open("w", encoding="utf-8", newline="\n") as sql:
  94. sql.write("-- DMS full demo data export (data only; no schema or secrets)\n")
  95. sql.write("SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\nSTART TRANSACTION;\n")
  96. for name in reversed(TABLES):
  97. sql.write(f"DELETE FROM `{name}`;\n")
  98. for name in TABLES:
  99. table = reflected[name]
  100. # MySQL generated columns (for example active_marker) must not be
  101. # supplied explicitly in INSERT statements.
  102. columns = [
  103. column.name
  104. for column in table.columns
  105. if column.computed is None
  106. ]
  107. quoted_columns = ", ".join(f"`{column}`" for column in columns)
  108. for row in rows_by_table[name]:
  109. values = ", ".join(sql_literal(row.get(column)) for column in columns)
  110. sql.write(f"INSERT INTO `{name}` ({quoted_columns}) VALUES ({values});\n")
  111. sql.write("COMMIT;\nSET FOREIGN_KEY_CHECKS=1;\n")
  112. manifest = {
  113. "format": "DMS full demo data v1",
  114. "database_tables": {name: len(rows_by_table[name]) for name in TABLES},
  115. "storage_file_count": len(document_paths),
  116. "storage_total_bytes": sum(item["size"] for item in document_paths.values()),
  117. "storage_files": document_paths,
  118. "sql_sha256": sha256(sql_path),
  119. }
  120. (output / "manifest.json").write_text(
  121. json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
  122. encoding="utf-8",
  123. )
  124. print(json.dumps(manifest, ensure_ascii=False, indent=2))
  125. def main() -> None:
  126. parser = argparse.ArgumentParser()
  127. parser.add_argument("--env-file", type=Path, required=True)
  128. parser.add_argument("--storage", type=Path, required=True)
  129. parser.add_argument("--output", type=Path, required=True)
  130. arguments = parser.parse_args()
  131. export(arguments.env_file.resolve(), arguments.storage.resolve(), arguments.output.resolve())
  132. if __name__ == "__main__":
  133. main()