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.
- muscles/data/sql/__init__.py +114 -0
- muscles/data/sql/cli.py +76 -0
- muscles/data/sql/config.py +50 -0
- muscles/data/sql/connections.py +105 -0
- muscles/data/sql/data_adapter.py +71 -0
- muscles/data/sql/drivers.py +52 -0
- muscles/data/sql/engine.py +19 -0
- muscles/data/sql/inspect.py +26 -0
- muscles/data/sql/mapping.py +71 -0
- muscles/data/sql/migration_lock.py +53 -0
- muscles/data/sql/model_mapper.py +30 -0
- muscles/data/sql/native.py +131 -0
- muscles/data/sql/native_migrations.py +200 -0
- muscles/data/sql/providers.py +30 -0
- muscles/data/sql/query.py +33 -0
- muscles/data/sql/repository.py +438 -0
- muscles/data/sql/sqlalchemy_compat.py +18 -0
- muscles/data/sql/uow.py +47 -0
- muscles_data_sql-2.0.0.dist-info/METADATA +223 -0
- muscles_data_sql-2.0.0.dist-info/RECORD +23 -0
- muscles_data_sql-2.0.0.dist-info/WHEEL +5 -0
- muscles_data_sql-2.0.0.dist-info/entry_points.txt +2 -0
- muscles_data_sql-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
muscles/data/sql/cli.py
ADDED
|
@@ -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()}
|