muscles-data-sql 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .native_migrations import NativeSqlMigration, NativeSqlMigrationBackend, NativeSqlMigrationSourceBackend
7
+ from .drivers import SqlDriverCatalog
8
+ from .native import SqlDatabase, SqlTransaction
9
+ from .model_mapper import ModelMapper
10
+ from .config import DatabaseConfig
11
+ from .config import SqlConnectionConfig
12
+ from .connections import SqlConnectionRegistry, UnknownSqlConnection
13
+ from .engine import EngineManager
14
+ from .mapping import map_model
15
+ from .providers import SqlResourceGeneratorProvider
16
+ from .query import FilterClause, JoinClause, QuerySpec
17
+ from .repository import SqlRepository
18
+ from .uow import UnitOfWork
19
+ from .sqlalchemy_compat import text
20
+
21
+ __all__ = [
22
+ "NativeSqlMigration",
23
+ "NativeSqlMigrationBackend",
24
+ "NativeSqlMigrationSourceBackend",
25
+ "SqlDriverCatalog",
26
+ "SqlDatabase",
27
+ "SqlTransaction",
28
+ "ModelMapper",
29
+ "DatabaseConfig",
30
+ "SqlConnectionConfig",
31
+ "SqlConnectionRegistry",
32
+ "UnknownSqlConnection",
33
+ "EngineManager",
34
+ "map_model",
35
+ "SqlRepository",
36
+ "UnitOfWork",
37
+ "text",
38
+ "QuerySpec",
39
+ "FilterClause",
40
+ "JoinClause",
41
+ "SqlResourceGeneratorProvider",
42
+ ]
43
+
44
+
45
+ def __getattr__(name: str):
46
+ if name in {"NativeSqlMigration", "NativeSqlMigrationBackend", "NativeSqlMigrationSourceBackend"}:
47
+ from . import native_migrations
48
+
49
+ return getattr(native_migrations, name)
50
+ if name == "SqlDriverCatalog":
51
+ from .drivers import SqlDriverCatalog
52
+
53
+ return SqlDriverCatalog
54
+ if name == "ModelMapper":
55
+ from .model_mapper import ModelMapper
56
+
57
+ return ModelMapper
58
+ if name in {"SqlDatabase", "SqlTransaction"}:
59
+ from .native import SqlDatabase, SqlTransaction
60
+
61
+ return {"SqlDatabase": SqlDatabase, "SqlTransaction": SqlTransaction}[name]
62
+ if name == "DatabaseConfig":
63
+ from .config import DatabaseConfig
64
+
65
+ return DatabaseConfig
66
+ if name == "SqlConnectionConfig":
67
+ from .config import SqlConnectionConfig
68
+
69
+ return SqlConnectionConfig
70
+ if name == "SqlConnectionRegistry":
71
+ from .connections import SqlConnectionRegistry
72
+
73
+ return SqlConnectionRegistry
74
+ if name == "UnknownSqlConnection":
75
+ from .connections import UnknownSqlConnection
76
+
77
+ return UnknownSqlConnection
78
+ if name == "EngineManager":
79
+ from .engine import EngineManager
80
+
81
+ return EngineManager
82
+ if name == "map_model":
83
+ from .mapping import map_model
84
+
85
+ return map_model
86
+ if name == "SqlRepository":
87
+ from .repository import SqlRepository
88
+
89
+ return SqlRepository
90
+ if name == "UnitOfWork":
91
+ from .uow import UnitOfWork
92
+
93
+ return UnitOfWork
94
+ if name == "text":
95
+ from .sqlalchemy_compat import text
96
+
97
+ return text
98
+ if name == "QuerySpec":
99
+ from .query import QuerySpec
100
+
101
+ return QuerySpec
102
+ if name == "FilterClause":
103
+ from .query import FilterClause
104
+
105
+ return FilterClause
106
+ if name == "JoinClause":
107
+ from .query import JoinClause
108
+
109
+ return JoinClause
110
+ if name == "SqlResourceGeneratorProvider":
111
+ from .providers import SqlResourceGeneratorProvider
112
+
113
+ return SqlResourceGeneratorProvider
114
+ raise AttributeError(name)
@@ -0,0 +1,76 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import click
5
+
6
+ from .config import DatabaseConfig
7
+ from .connections import SqlConnectionRegistry, load_connection_registry
8
+ from .providers import SqlGenerationRequest, SqlResourceGeneratorProvider
9
+
10
+
11
+ @click.group()
12
+ def main():
13
+ pass
14
+
15
+
16
+ def _registry_from_options(url: str | None, config_path: str | None) -> SqlConnectionRegistry:
17
+ if config_path:
18
+ return load_connection_registry(config_path)
19
+ if url:
20
+ return SqlConnectionRegistry.from_database_config(DatabaseConfig(url=url))
21
+ raise click.UsageError("Use --url or --config.")
22
+
23
+
24
+ @main.command("doctor")
25
+ @click.option("--url", required=False)
26
+ @click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False), required=False)
27
+ @click.option("--connection", default="default", show_default=True)
28
+ @click.option("--all", "all_connections", is_flag=True, default=False)
29
+ def doctor(url: str | None, config_path: str | None, connection: str, all_connections: bool):
30
+ registry = _registry_from_options(url, config_path)
31
+ report = registry.inspect_all() if all_connections else registry.inspect(connection)
32
+ click.echo(json.dumps(report, ensure_ascii=False))
33
+
34
+
35
+ @main.command("inspect")
36
+ @click.option("--url", required=False)
37
+ @click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False), required=False)
38
+ @click.option("--connection", default="default", show_default=True)
39
+ def inspect_cmd(url: str | None, config_path: str | None, connection: str):
40
+ registry = _registry_from_options(url, config_path)
41
+ click.echo(json.dumps(registry.inspect(connection), ensure_ascii=False))
42
+
43
+
44
+ @main.group("generate")
45
+ def generate():
46
+ pass
47
+
48
+
49
+ @generate.command("resource")
50
+ @click.argument("name")
51
+ @click.option("--sql", is_flag=True, default=False)
52
+ def generate_resource(name: str, sql: bool):
53
+ if sql:
54
+ provider = SqlResourceGeneratorProvider()
55
+ generated = provider.generate(
56
+ project_root=Path.cwd(),
57
+ request=SqlGenerationRequest(
58
+ generator_type="sql-resource",
59
+ name=name,
60
+ force=False,
61
+ with_tests=False,
62
+ ),
63
+ )
64
+ click.echo(generated[0])
65
+ return
66
+
67
+ root = Path("generated")
68
+ root.mkdir(exist_ok=True)
69
+ target = root / f"{name}.py"
70
+ content = f"class {name.title().replace('_', '')}Resource:\n sql_enabled = {sql}\n"
71
+ target.write_text(content, encoding="utf-8")
72
+ click.echo(str(target))
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class DatabaseConfig:
7
+ url: str
8
+ echo: bool = False
9
+ pool_size: int = 5
10
+ max_overflow: int = 10
11
+ future: bool = True
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class SqlConnectionConfig:
16
+ name: str
17
+ url: str
18
+ echo: bool = False
19
+ pool_size: int = 5
20
+ max_overflow: int = 10
21
+ future: bool = True
22
+ role: str | None = None
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+
25
+ @property
26
+ def safe_url(self) -> str:
27
+ from sqlalchemy.engine import make_url
28
+
29
+ parsed = make_url(self.url)
30
+ rendered = parsed.render_as_string(hide_password=True)
31
+ if parsed.get_backend_name() == "sqlite" and parsed.database == ":memory:":
32
+ rendered = rendered.replace("%3Amemory%3A", ":memory:")
33
+ return rendered
34
+
35
+ def to_database_config(self) -> DatabaseConfig:
36
+ return DatabaseConfig(
37
+ url=self.url,
38
+ echo=self.echo,
39
+ pool_size=self.pool_size,
40
+ max_overflow=self.max_overflow,
41
+ future=self.future,
42
+ )
43
+
44
+ def to_contract(self) -> dict[str, Any]:
45
+ return {
46
+ "name": self.name,
47
+ "url": self.safe_url,
48
+ "role": self.role,
49
+ "metadata": dict(self.metadata),
50
+ }
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Iterable, Mapping
5
+
6
+ from .config import DatabaseConfig, SqlConnectionConfig
7
+ from .engine import EngineManager
8
+
9
+
10
+ class UnknownSqlConnection(KeyError):
11
+ def __init__(self, name: str):
12
+ super().__init__(f"Unknown SQL connection: {name}")
13
+ self.name = name
14
+
15
+
16
+ class SqlConnectionRegistry:
17
+ def __init__(self, configs: Iterable[SqlConnectionConfig] | None = None):
18
+ self._configs: dict[str, SqlConnectionConfig] = {}
19
+ self._managers: dict[str, EngineManager] = {}
20
+ for config in configs or ():
21
+ self.register(config)
22
+
23
+ @classmethod
24
+ def from_database_config(cls, config: DatabaseConfig, *, name: str = "default") -> "SqlConnectionRegistry":
25
+ return cls(
26
+ [
27
+ SqlConnectionConfig(
28
+ name=name,
29
+ url=config.url,
30
+ echo=config.echo,
31
+ pool_size=config.pool_size,
32
+ max_overflow=config.max_overflow,
33
+ future=config.future,
34
+ )
35
+ ]
36
+ )
37
+
38
+ def register(self, config: SqlConnectionConfig) -> None:
39
+ self._configs[config.name] = config
40
+ self._managers.pop(config.name, None)
41
+
42
+ def names(self) -> list[str]:
43
+ return sorted(self._configs)
44
+
45
+ def config(self, name: str = "default") -> SqlConnectionConfig:
46
+ try:
47
+ return self._configs[name]
48
+ except KeyError as exc:
49
+ raise UnknownSqlConnection(name) from exc
50
+
51
+ def manager(self, name: str = "default") -> EngineManager:
52
+ if name not in self._managers:
53
+ self._managers[name] = EngineManager(self.config(name).to_database_config())
54
+ return self._managers[name]
55
+
56
+ def session_factory(self, name: str = "default"):
57
+ return self.manager(name).session_factory
58
+
59
+ def session(self, name: str = "default"):
60
+ return self.manager(name).session()
61
+
62
+ def inspect(self, name: str = "default") -> dict[str, Any]:
63
+ from muscles.data.sql.inspect import inspect_sql_layer
64
+
65
+ config = self.config(name)
66
+ report = inspect_sql_layer(self.manager(name))
67
+ report["connection"] = config.to_contract()
68
+ return report
69
+
70
+ def inspect_all(self) -> dict[str, Any]:
71
+ reports = {name: self.inspect(name) for name in self.names()}
72
+ status = "ok" if all(report.get("status") == "ok" for report in reports.values()) else "error"
73
+ return {"status": status, "connections": reports}
74
+
75
+
76
+ def connection_config_from_mapping(name: str, payload: str | Mapping[str, Any]) -> SqlConnectionConfig:
77
+ if isinstance(payload, str):
78
+ return SqlConnectionConfig(name=name, url=payload)
79
+ return SqlConnectionConfig(
80
+ name=str(payload.get("name", name)),
81
+ url=str(payload["url"]),
82
+ echo=bool(payload.get("echo", False)),
83
+ pool_size=int(payload.get("pool_size", 5)),
84
+ max_overflow=int(payload.get("max_overflow", 10)),
85
+ future=bool(payload.get("future", True)),
86
+ role=payload.get("role"),
87
+ metadata=dict(payload.get("metadata") or {}),
88
+ )
89
+
90
+
91
+ def connection_registry_from_mapping(payload: Mapping[str, Any]) -> SqlConnectionRegistry:
92
+ raw_connections = payload.get("connections", payload)
93
+ if not isinstance(raw_connections, Mapping):
94
+ raise ValueError("SQL connection config must contain a mapping of connections.")
95
+ configs = [connection_config_from_mapping(str(name), value) for name, value in raw_connections.items()]
96
+ return SqlConnectionRegistry(configs)
97
+
98
+
99
+ def load_connection_registry(path: str | Path) -> SqlConnectionRegistry:
100
+ import json
101
+
102
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
103
+ if not isinstance(payload, Mapping):
104
+ raise ValueError("SQL connection config must be a JSON object.")
105
+ return connection_registry_from_mapping(payload)
@@ -0,0 +1,71 @@
1
+ """Optional integration with muscles-data; the SQL core does not import it."""
2
+ from __future__ import annotations
3
+
4
+ from threading import Lock
5
+ from typing import Mapping
6
+
7
+ from muscles.data.errors import DataConfigurationError
8
+ from muscles.data.models import DataCapability, HealthResult
9
+
10
+ from .drivers import SqlDriverCatalog, SqlDriverConfigError, SqlDriverFactory, SqlDriverNotFoundError
11
+
12
+
13
+ class NativeSqlAdapter:
14
+ resource_type = "native_sql"
15
+
16
+ def __init__(self, config, drivers: SqlDriverCatalog):
17
+ self.config = config
18
+ self._drivers = drivers
19
+ self._db = None
20
+ self._lock = Lock()
21
+ self._closed = False
22
+
23
+ def _database(self):
24
+ with self._lock:
25
+ if self._closed:
26
+ raise RuntimeError("SQL resource is closed")
27
+ if self._db is None:
28
+ options = self.config.resolved_options()
29
+ driver = options.get("driver")
30
+ try:
31
+ self._db = self._drivers.open(driver, options)
32
+ except (SqlDriverNotFoundError, SqlDriverConfigError) as exc:
33
+ raise DataConfigurationError(str(exc)) from exc
34
+ return self._db
35
+
36
+ def transaction(self):
37
+ return self._database().transaction()
38
+
39
+ def inspect(self):
40
+ return {"status": "ok", "details": {"driver": self.config.options.get("driver")}}
41
+
42
+ def health(self):
43
+ try:
44
+ self._database().check_health()
45
+ except Exception:
46
+ return HealthResult(status="failed", message="SQL health check failed")
47
+ return HealthResult(status="ok")
48
+
49
+ def close(self):
50
+ with self._lock:
51
+ if self._db is not None:
52
+ self._db.close()
53
+ self._closed = True
54
+
55
+
56
+ class NativeSqlFactory:
57
+ resource_type = "native_sql"
58
+
59
+ def __init__(self, *, drivers: SqlDriverCatalog | Mapping[str, SqlDriverFactory] | None = None):
60
+ if isinstance(drivers, SqlDriverCatalog):
61
+ self._drivers = drivers
62
+ else:
63
+ self._drivers = SqlDriverCatalog.with_defaults()
64
+ for name, factory in (drivers or {}).items():
65
+ self._drivers.register(name, factory)
66
+
67
+ def capabilities(self, config):
68
+ return {DataCapability.SQL_TRANSACTION, DataCapability.HEALTHCHECK}
69
+
70
+ def create(self, config):
71
+ return NativeSqlAdapter(config, self._drivers)
@@ -0,0 +1,52 @@
1
+ """Registration of independent SQL connection factories."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable, Mapping
5
+
6
+ from .native import SqlDatabase
7
+
8
+ SqlDriverFactory = Callable[[Mapping[str, Any]], SqlDatabase]
9
+
10
+
11
+ class SqlDriverNotFoundError(LookupError):
12
+ """The requested driver has not been registered."""
13
+
14
+
15
+ class SqlDriverConfigError(ValueError):
16
+ """A driver's required configuration is missing."""
17
+
18
+
19
+ def _sqlite_driver(options: Mapping[str, Any]) -> SqlDatabase:
20
+ if not options.get("path"):
21
+ raise SqlDriverConfigError("sqlite driver requires path")
22
+ return SqlDatabase.sqlite(options["path"])
23
+
24
+
25
+ def _postgres_driver(options: Mapping[str, Any]) -> SqlDatabase:
26
+ if not options.get("url"):
27
+ raise SqlDriverConfigError("postgres driver requires url or url_env")
28
+ return SqlDatabase.postgres(options["url"])
29
+
30
+
31
+ class SqlDriverCatalog:
32
+ def __init__(self) -> None:
33
+ self._drivers: dict[str, SqlDriverFactory] = {}
34
+
35
+ @classmethod
36
+ def with_defaults(cls) -> SqlDriverCatalog:
37
+ catalog = cls()
38
+ catalog.register("sqlite", _sqlite_driver)
39
+ catalog.register("postgres", _postgres_driver)
40
+ return catalog
41
+
42
+ def register(self, name: str, factory: SqlDriverFactory) -> None:
43
+ if not isinstance(name, str) or not name.strip() or not callable(factory):
44
+ raise ValueError("SQL drivers require a nonempty name and callable factory")
45
+ if name in self._drivers:
46
+ raise ValueError(f"SQL driver '{name}' is already registered")
47
+ self._drivers[name] = factory
48
+
49
+ def open(self, name: str, options: Mapping[str, Any]) -> SqlDatabase:
50
+ if not isinstance(name, str) or name not in self._drivers:
51
+ raise SqlDriverNotFoundError("SQL driver is not registered")
52
+ return self._drivers[name](options)
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from .config import DatabaseConfig
4
+
5
+
6
+ class EngineManager:
7
+ def __init__(self, config: DatabaseConfig):
8
+ from sqlalchemy import create_engine
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ kwargs: dict[str, Any] = {"echo": config.echo, "future": config.future}
12
+ if not config.url.startswith("sqlite"):
13
+ kwargs["pool_size"] = config.pool_size
14
+ kwargs["max_overflow"] = config.max_overflow
15
+ self.engine = create_engine(config.url, **kwargs)
16
+ self.session_factory = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, future=True)
17
+
18
+ def session(self):
19
+ return self.session_factory()
@@ -0,0 +1,26 @@
1
+ def inspect_sql_layer(engine_manager) -> dict:
2
+ from time import perf_counter
3
+ from sqlalchemy import inspect, text
4
+
5
+ started = perf_counter()
6
+ with engine_manager.engine.connect() as conn:
7
+ conn.execute(text("SELECT 1"))
8
+ latency_ms = round((perf_counter() - started) * 1000, 3)
9
+ inspector = inspect(engine_manager.engine)
10
+ tables = []
11
+ for table_name in inspector.get_table_names():
12
+ tables.append(
13
+ {
14
+ "name": table_name,
15
+ "columns": [column["name"] for column in inspector.get_columns(table_name)],
16
+ "indexes": [index["name"] for index in inspector.get_indexes(table_name)],
17
+ "pk": inspector.get_pk_constraint(table_name).get("constrained_columns", []),
18
+ }
19
+ )
20
+ return {
21
+ "status": "ok",
22
+ "dialect": engine_manager.engine.dialect.name,
23
+ "driver": engine_manager.engine.dialect.driver,
24
+ "latency_ms": latency_ms,
25
+ "tables": tables,
26
+ }
@@ -0,0 +1,71 @@
1
+ from typing import Any
2
+
3
+ TYPE_MAP: dict[str, str] = {
4
+ "String": "String",
5
+ "Text": "Text",
6
+ "Integer": "Integer",
7
+ "Numeric": "Numeric",
8
+ "Float": "Float",
9
+ "Boolean": "Boolean",
10
+ "Date": "Date",
11
+ "DateTime": "DateTime",
12
+ }
13
+
14
+
15
+ def _sa():
16
+ from sqlalchemy import Boolean, Column, Date, DateTime, Float, Integer, MetaData, Numeric, String, Table, Text
17
+
18
+ return {
19
+ "Boolean": Boolean,
20
+ "Column": Column,
21
+ "Date": Date,
22
+ "DateTime": DateTime,
23
+ "Float": Float,
24
+ "Integer": Integer,
25
+ "MetaData": MetaData,
26
+ "Numeric": Numeric,
27
+ "String": String,
28
+ "Table": Table,
29
+ "Text": Text,
30
+ }
31
+
32
+
33
+ def _resolve_type(column_type: Any):
34
+ sa = _sa()
35
+ if isinstance(column_type, type):
36
+ name = column_type.__name__
37
+ else:
38
+ name = getattr(column_type, "__class__", type(column_type)).__name__
39
+ return sa.get(TYPE_MAP.get(name, "String"), sa["String"])
40
+
41
+
42
+ def map_model(model_cls: type, table_name: str | None = None):
43
+ sa = _sa()
44
+ metadata = sa["MetaData"]()
45
+ columns = []
46
+ for field_name, field_def in model_cls.__dict__.items():
47
+ if field_name.startswith("_"):
48
+ continue
49
+ field_type = getattr(field_def, "field_type", None)
50
+ if field_type is None:
51
+ continue
52
+ sql_type = _resolve_type(field_type)
53
+ is_pk = bool(getattr(field_def, "primary_key", False))
54
+ nullable = bool(getattr(field_def, "nullable", not is_pk))
55
+ autoincrement = bool(
56
+ getattr(field_def, "autoincrement", False)
57
+ or (is_pk and sql_type is sa["Integer"])
58
+ )
59
+ columns.append(
60
+ sa["Column"](
61
+ field_name,
62
+ sql_type,
63
+ primary_key=is_pk,
64
+ nullable=nullable,
65
+ autoincrement=autoincrement,
66
+ )
67
+ )
68
+
69
+ if not columns:
70
+ raise ValueError(f"No mappable columns found in model {model_cls.__name__}")
71
+ return sa["Table"](table_name or model_cls.__name__.lower(), metadata, *columns, extend_existing=True)
@@ -0,0 +1,53 @@
1
+ """Optional SQL-backed coordination lock for the migration registry."""
2
+ from contextlib import contextmanager
3
+ from pathlib import Path
4
+
5
+
6
+ class SqlMigrationExecutionLock:
7
+ """All runners must use the same database URL and this lock implementation."""
8
+
9
+ def __init__(self, url: str):
10
+ self.url = url
11
+
12
+ @contextmanager
13
+ def __call__(self):
14
+ from sqlalchemy.engine import make_url
15
+
16
+ sqlalchemy_url = self.url.replace("postgresql+psycopg2://", "postgresql+psycopg://", 1)
17
+ sqlalchemy_url = sqlalchemy_url.replace("postgresql://", "postgresql+psycopg://", 1)
18
+ url = make_url(sqlalchemy_url)
19
+ dialect = url.get_backend_name()
20
+ if dialect == "sqlite":
21
+ import fcntl
22
+
23
+ database = url.database
24
+ if not database or database == ":memory:" or database.startswith("file:"):
25
+ raise ValueError("SQLite migration coordinator requires a shared file, not memory or a URI")
26
+ lock_path = Path(f"{Path(database).resolve()}.migrations.lock")
27
+ with lock_path.open("a+b") as handle:
28
+ try:
29
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
30
+ except BlockingIOError as exc:
31
+ raise RuntimeError("Migration execution is locked") from exc
32
+ try:
33
+ yield
34
+ finally:
35
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
36
+ elif dialect == "postgresql":
37
+ from sqlalchemy import create_engine, text
38
+ from sqlalchemy.pool import NullPool
39
+
40
+ engine = create_engine(url, poolclass=NullPool)
41
+ try:
42
+ with engine.connect() as connection:
43
+ acquired = connection.execute(text("SELECT pg_try_advisory_lock(:key)"), {"key": 778212474}).scalar_one()
44
+ if not acquired:
45
+ raise RuntimeError("Migration execution is locked")
46
+ try:
47
+ yield
48
+ finally:
49
+ connection.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": 778212474})
50
+ finally:
51
+ engine.dispose()
52
+ else:
53
+ raise ValueError("Migration coordinator supports only SQLite and PostgreSQL")
@@ -0,0 +1,30 @@
1
+ """Explicit mapping between Muscles models and SQL result records."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Generic, Iterable, Mapping, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class ModelMapper(Generic[T]):
10
+ def __init__(self, model_type: type[T], fields: Mapping[str, str]):
11
+ if not fields or len(set(fields.values())) != len(fields):
12
+ raise ValueError("fields must contain unique record names")
13
+ self.model_type = model_type
14
+ self.fields = dict(fields)
15
+
16
+ def from_record(self, record: Mapping[str, Any]) -> T:
17
+ model = self.model_type(**{field: record[column] for field, column in self.fields.items()})
18
+ unknown = self.fields.keys() - set(model.fields())
19
+ if unknown:
20
+ raise ValueError(f"Unknown model fields: {', '.join(sorted(unknown))}")
21
+ return model
22
+
23
+ def from_records(self, records: Iterable[Mapping[str, Any]]) -> list[T]:
24
+ return [self.from_record(record) for record in records]
25
+
26
+ def to_record(self, model: T) -> dict[str, Any]:
27
+ if not isinstance(model, self.model_type):
28
+ raise TypeError("model has an unexpected type")
29
+ values = model.as_dict()
30
+ return {column: values[field] for field, column in self.fields.items()}