Files
ecm_sync_system/sync_state_machine/common/persistence.py
T
2026-04-08 09:28:14 +08:00

313 lines
11 KiB
Python

from __future__ import annotations
import os
import sqlite3
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, Dict, List, Optional, TYPE_CHECKING
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import URL, make_url
from ..config.persist_config import PersistConfig
if TYPE_CHECKING:
from .sync_node import SyncNode
class PersistenceBackend(ABC):
"""同步系统持久化契约。"""
backend_name: ClassVar[str] = "abstract"
@staticmethod
def _is_read_only_sql(sql: str) -> bool:
stmt = (sql or "").strip().lower()
return stmt.startswith("select") or stmt.startswith("pragma") or stmt.startswith("with")
@classmethod
def query_records(
cls,
*,
backend: str,
db_path: str | None = None,
url: str | None = None,
sql: str,
limit: int = 200,
) -> tuple[List[str], List[Dict[str, Any]]]:
if not cls._is_read_only_sql(sql):
raise ValueError("Only read-only SQL is allowed")
if backend == "sqlite":
path = db_path
if not path:
raise ValueError("db_path is required for backend=sqlite")
if not os.path.exists(path):
raise FileNotFoundError(f"DB not found: {path}")
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(sql)
rows = cursor.fetchmany(max(1, min(limit, 2000)))
columns = [item[0] for item in (cursor.description or [])]
data = [{k: row[k] for k in columns} for row in rows]
return columns, data
if backend == "sqlalchemy":
if not url:
raise ValueError("url is required for backend=sqlalchemy")
engine = create_engine(cls._to_sync_sqlalchemy_url(url))
try:
with engine.connect() as conn:
result = conn.execute(text(sql))
rows = result.fetchmany(max(1, min(limit, 2000)))
columns = list(result.keys())
data = [{column: row[index] for index, column in enumerate(columns)} for row in rows]
return columns, data
finally:
engine.dispose()
raise NotImplementedError(f"records query backend not supported yet: {backend}")
@classmethod
def records_summary(
cls,
*,
backend: str,
db_path: str | None = None,
url: str | None = None,
) -> Dict[str, Any]:
db_exists = cls._target_exists(backend=backend, db_path=db_path, url=url)
summary: Dict[str, Any] = {
"backend": backend,
"db_path": db_path,
"url": url,
"db_exists": db_exists,
"tables": [],
"sample_bindings": [],
}
if not db_exists:
return summary
if backend == "sqlite":
assert db_path is not None
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
table_names = [str(r["name"]) for r in tables]
for name in table_names:
count = conn.execute(f"SELECT COUNT(1) AS c FROM {name}").fetchone()["c"]
summary["tables"].append({"name": name, "count": int(count)})
if "bindings" in table_names:
sample = conn.execute(
"SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50"
).fetchall()
summary["sample_bindings"] = [
{
"node_type": row["node_type"],
"local_id": row["local_id"],
"remote_id": row["remote_id"],
}
for row in sample
]
return summary
if backend == "sqlalchemy":
if not url:
summary["error"] = "url is required for backend=sqlalchemy"
return summary
engine = create_engine(cls._to_sync_sqlalchemy_url(url))
try:
with engine.connect() as conn:
inspector = inspect(conn)
table_names = sorted(inspector.get_table_names())
for name in table_names:
count = conn.execute(text(f"SELECT COUNT(1) AS c FROM {name}")).scalar_one()
summary["tables"].append({"name": name, "count": int(count)})
if "bindings" in table_names:
sample = conn.execute(
text("SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50")
).fetchall()
summary["sample_bindings"] = [
{
"node_type": row[0],
"local_id": row[1],
"remote_id": row[2],
}
for row in sample
]
finally:
engine.dispose()
return summary
summary["error"] = f"records summary backend not supported yet: {backend}"
return summary
@staticmethod
def _target_exists(*, backend: str, db_path: str | None, url: str | None) -> bool:
if backend == "sqlite":
return bool(db_path) and os.path.exists(db_path)
if backend == "sqlalchemy":
if not url:
return False
parsed = make_url(url)
if parsed.get_backend_name() == "sqlite":
database = parsed.database
if not database or database == ":memory:":
return True
return os.path.exists(database)
return True
return False
@staticmethod
def _to_sync_sqlalchemy_url(url: str) -> str:
parsed = make_url(url)
async_to_sync = {
"sqlite+aiosqlite": "sqlite+pysqlite",
"postgresql+asyncpg": "postgresql+psycopg",
"mysql+aiomysql": "mysql+pymysql",
}
drivername = async_to_sync.get(parsed.drivername, parsed.drivername)
sync_url: URL = parsed.set(drivername=drivername)
return str(sync_url)
@abstractmethod
async def initialize(self) -> None:
raise NotImplementedError
@abstractmethod
async def save_node(self, collection_id: str, scope: str, node: "SyncNode") -> None:
raise NotImplementedError
@abstractmethod
async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]) -> None:
raise NotImplementedError
@abstractmethod
async def delete_node(self, collection_id: str, scope: str, node_id: str) -> None:
raise NotImplementedError
@abstractmethod
async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def load_nodes(
self,
collection_id: str,
scope: str,
*,
exclude_node_types: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]) -> None:
raise NotImplementedError
@abstractmethod
async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]) -> None:
raise NotImplementedError
@abstractmethod
async def delete_binding(self, scope: str, node_type: str, local_id: str) -> None:
raise NotImplementedError
@abstractmethod
async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]:
raise NotImplementedError
@abstractmethod
async def delete_scope_node_types(self, *, scope: str, node_types: List[str]) -> None:
raise NotImplementedError
@abstractmethod
async def copy_nodes_between_scopes(
self,
*,
collection_id: str,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
raise NotImplementedError
@abstractmethod
async def copy_bindings_between_scopes(
self,
*,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
raise NotImplementedError
@abstractmethod
async def dump_to_runtime(self, filename: str) -> None:
raise NotImplementedError
@abstractmethod
async def close(self) -> None:
raise NotImplementedError
PersistenceBackendFactory = Callable[[PersistConfig], PersistenceBackend]
_PERSISTENCE_BACKEND_REGISTRY: dict[str, PersistenceBackendFactory] = {}
def register_persistence_backend(name: str, factory: PersistenceBackendFactory, *, override: bool = False) -> None:
backend_name = name.strip()
if not backend_name:
raise ValueError("backend name cannot be empty")
if not override and backend_name in _PERSISTENCE_BACKEND_REGISTRY:
raise ValueError(f"persistence backend already registered: {backend_name}")
_PERSISTENCE_BACKEND_REGISTRY[backend_name] = factory
def get_registered_persistence_backends() -> Dict[str, PersistenceBackendFactory]:
return dict(_PERSISTENCE_BACKEND_REGISTRY)
def create_persistence_backend(
persist_config: PersistConfig,
) -> PersistenceBackend:
backend_name = (persist_config.backend or "").strip()
if not backend_name:
raise ValueError("persist.backend cannot be empty")
if not _PERSISTENCE_BACKEND_REGISTRY:
register_persistence_backend(
"sqlite",
lambda config: SQLitePersistenceBackend(config),
)
factory = _PERSISTENCE_BACKEND_REGISTRY.get(backend_name)
if factory is None:
available = ", ".join(sorted(_PERSISTENCE_BACKEND_REGISTRY)) or "none"
raise NotImplementedError(f"Persistence backend not supported yet: {backend_name}. Available: {available}")
return factory(persist_config)
def _register_builtin_persistence_backends() -> None:
from .persistence_backends.sqlalchemy_backend import SQLAlchemyPersistenceBackend
from .persistence_backends.sqlite_backend import SQLitePersistenceBackend
register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True)
register_persistence_backend("sqlalchemy", lambda config: SQLAlchemyPersistenceBackend(config), override=True)
_register_builtin_persistence_backends()