muscles-sql 1.0.0__tar.gz

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.
Files changed (30) hide show
  1. muscles_sql-1.0.0/PKG-INFO +103 -0
  2. muscles_sql-1.0.0/README.md +87 -0
  3. muscles_sql-1.0.0/pyproject.toml +36 -0
  4. muscles_sql-1.0.0/setup.cfg +4 -0
  5. muscles_sql-1.0.0/src/muscles_sql/__init__.py +81 -0
  6. muscles_sql-1.0.0/src/muscles_sql/cli.py +144 -0
  7. muscles_sql-1.0.0/src/muscles_sql/config.py +46 -0
  8. muscles_sql-1.0.0/src/muscles_sql/connections.py +105 -0
  9. muscles_sql-1.0.0/src/muscles_sql/engine.py +19 -0
  10. muscles_sql-1.0.0/src/muscles_sql/inspect.py +26 -0
  11. muscles_sql-1.0.0/src/muscles_sql/mapping.py +71 -0
  12. muscles_sql-1.0.0/src/muscles_sql/migrations.py +132 -0
  13. muscles_sql-1.0.0/src/muscles_sql/providers.py +30 -0
  14. muscles_sql-1.0.0/src/muscles_sql/query.py +33 -0
  15. muscles_sql-1.0.0/src/muscles_sql/repository.py +227 -0
  16. muscles_sql-1.0.0/src/muscles_sql/uow.py +47 -0
  17. muscles_sql-1.0.0/src/muscles_sql.egg-info/PKG-INFO +103 -0
  18. muscles_sql-1.0.0/src/muscles_sql.egg-info/SOURCES.txt +28 -0
  19. muscles_sql-1.0.0/src/muscles_sql.egg-info/dependency_links.txt +1 -0
  20. muscles_sql-1.0.0/src/muscles_sql.egg-info/entry_points.txt +2 -0
  21. muscles_sql-1.0.0/src/muscles_sql.egg-info/requires.txt +8 -0
  22. muscles_sql-1.0.0/src/muscles_sql.egg-info/top_level.txt +1 -0
  23. muscles_sql-1.0.0/tests/test_cli.py +51 -0
  24. muscles_sql-1.0.0/tests/test_connections.py +89 -0
  25. muscles_sql-1.0.0/tests/test_di_core_compat_smoke.py +17 -0
  26. muscles_sql-1.0.0/tests/test_engine_and_inspect.py +12 -0
  27. muscles_sql-1.0.0/tests/test_lazy_imports.py +43 -0
  28. muscles_sql-1.0.0/tests/test_mapping.py +52 -0
  29. muscles_sql-1.0.0/tests/test_migrations.py +48 -0
  30. muscles_sql-1.0.0/tests/test_repository_and_uow.py +183 -0
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-sql
3
+ Version: 1.0.0
4
+ Summary: Muscles SQL data layer for repositories, migrations and AI-first workflow.
5
+ Author-email: "Denis B." <denis@butko.info>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: muscles<2.0.0,>=1.0.0rc1
10
+ Requires-Dist: SQLAlchemy<3.0,>=2.0
11
+ Requires-Dist: alembic<2.0,>=1.13
12
+ Requires-Dist: click<9.0,>=8.1
13
+ Requires-Dist: pydantic<3.0,>=2.7
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
16
+
17
+ # Muscles SQL
18
+
19
+ Muscles SQL is a data layer package for SQL databases:
20
+ - model-to-table mapping
21
+ - engine and session management
22
+ - repository CRUD and advanced query API (filters/operators/joins/aggregates)
23
+ - transactions and Unit of Work (nested/savepoint/retry helpers)
24
+ - migrations v2 commands (Alembic-compatible lazy-load)
25
+ - inspect/doctor support with machine-readable diagnostics
26
+
27
+ ## Related Repositories
28
+
29
+ - [`muscles`](https://github.com/butkoden/muscles) - core schemas, actions, DI and canonical documentation.
30
+ - [`muscles-documents`](https://github.com/butkoden/muscles-documents) - document metadata/state can use SQL persistence in applications.
31
+ - [`muscles-ai`](https://github.com/butkoden/muscles-ai) - AI/RAG flows can use SQL-backed application state without making SQL generic storage.
32
+ - [`muscles-otel`](https://github.com/butkoden/muscles-otel) - observability hooks around SQL-backed flows.
33
+ - [`muscles-benchmarks`](https://github.com/butkoden/muscles-benchmarks) - SQL transaction and mapping regression checks.
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ python -m venv .venv
39
+ source .venv/bin/activate
40
+ pip install muscles-sql
41
+ ```
42
+
43
+ For local development with the test dependencies:
44
+
45
+ ```bash
46
+ pip install -e ".[dev]"
47
+ pytest -q
48
+ muscles-sql doctor --url sqlite:///./app.db
49
+ ```
50
+
51
+ ## Named SQL Connections
52
+
53
+ `muscles-sql` can manage multiple SQL connections without becoming a generic
54
+ storage registry. The registry is SQL-only: it owns SQL connection configs,
55
+ lazy SQLAlchemy `EngineManager` instances, sessions, inspect and doctor reports.
56
+
57
+ ```python
58
+ from muscles_sql import SqlConnectionConfig, SqlConnectionRegistry
59
+
60
+ registry = SqlConnectionRegistry(
61
+ [
62
+ SqlConnectionConfig(name="default", url="sqlite:///./app.db"),
63
+ SqlConnectionConfig(name="analytics", url="sqlite:///./analytics.db", role="read"),
64
+ ]
65
+ )
66
+
67
+ session = registry.session("analytics")
68
+ report = registry.inspect("analytics")
69
+ ```
70
+
71
+ CLI diagnostics can read a JSON config:
72
+
73
+ ```json
74
+ {
75
+ "connections": {
76
+ "default": "sqlite:///./app.db",
77
+ "analytics": {"url": "sqlite:///./analytics.db", "role": "read"}
78
+ }
79
+ }
80
+ ```
81
+
82
+ ```bash
83
+ muscles-sql inspect --config sql-connections.json --connection analytics
84
+ muscles-sql doctor --config sql-connections.json --all
85
+ ```
86
+
87
+ Diagnostic output uses safe URLs and does not print passwords from DSNs.
88
+
89
+ ## Advanced Query Example
90
+
91
+ ```python
92
+ from sqlalchemy import func
93
+ from muscles_sql import FilterClause, JoinClause, QuerySpec, SqlRepository
94
+
95
+ spec = QuerySpec(
96
+ filters=[FilterClause("status", "eq", "active")],
97
+ joins=[JoinClause(table=orders, on=users.c.id == orders.c.user_id)],
98
+ select_columns=[users.c.id, func.count(orders.c.id).label("orders_total")],
99
+ group_by=[users.c.id],
100
+ order_by=[users.c.id.asc()],
101
+ )
102
+ rows = SqlRepository(session, users).aggregate(spec)
103
+ ```
@@ -0,0 +1,87 @@
1
+ # Muscles SQL
2
+
3
+ Muscles SQL is a data layer package for SQL databases:
4
+ - model-to-table mapping
5
+ - engine and session management
6
+ - repository CRUD and advanced query API (filters/operators/joins/aggregates)
7
+ - transactions and Unit of Work (nested/savepoint/retry helpers)
8
+ - migrations v2 commands (Alembic-compatible lazy-load)
9
+ - inspect/doctor support with machine-readable diagnostics
10
+
11
+ ## Related Repositories
12
+
13
+ - [`muscles`](https://github.com/butkoden/muscles) - core schemas, actions, DI and canonical documentation.
14
+ - [`muscles-documents`](https://github.com/butkoden/muscles-documents) - document metadata/state can use SQL persistence in applications.
15
+ - [`muscles-ai`](https://github.com/butkoden/muscles-ai) - AI/RAG flows can use SQL-backed application state without making SQL generic storage.
16
+ - [`muscles-otel`](https://github.com/butkoden/muscles-otel) - observability hooks around SQL-backed flows.
17
+ - [`muscles-benchmarks`](https://github.com/butkoden/muscles-benchmarks) - SQL transaction and mapping regression checks.
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ python -m venv .venv
23
+ source .venv/bin/activate
24
+ pip install muscles-sql
25
+ ```
26
+
27
+ For local development with the test dependencies:
28
+
29
+ ```bash
30
+ pip install -e ".[dev]"
31
+ pytest -q
32
+ muscles-sql doctor --url sqlite:///./app.db
33
+ ```
34
+
35
+ ## Named SQL Connections
36
+
37
+ `muscles-sql` can manage multiple SQL connections without becoming a generic
38
+ storage registry. The registry is SQL-only: it owns SQL connection configs,
39
+ lazy SQLAlchemy `EngineManager` instances, sessions, inspect and doctor reports.
40
+
41
+ ```python
42
+ from muscles_sql import SqlConnectionConfig, SqlConnectionRegistry
43
+
44
+ registry = SqlConnectionRegistry(
45
+ [
46
+ SqlConnectionConfig(name="default", url="sqlite:///./app.db"),
47
+ SqlConnectionConfig(name="analytics", url="sqlite:///./analytics.db", role="read"),
48
+ ]
49
+ )
50
+
51
+ session = registry.session("analytics")
52
+ report = registry.inspect("analytics")
53
+ ```
54
+
55
+ CLI diagnostics can read a JSON config:
56
+
57
+ ```json
58
+ {
59
+ "connections": {
60
+ "default": "sqlite:///./app.db",
61
+ "analytics": {"url": "sqlite:///./analytics.db", "role": "read"}
62
+ }
63
+ }
64
+ ```
65
+
66
+ ```bash
67
+ muscles-sql inspect --config sql-connections.json --connection analytics
68
+ muscles-sql doctor --config sql-connections.json --all
69
+ ```
70
+
71
+ Diagnostic output uses safe URLs and does not print passwords from DSNs.
72
+
73
+ ## Advanced Query Example
74
+
75
+ ```python
76
+ from sqlalchemy import func
77
+ from muscles_sql import FilterClause, JoinClause, QuerySpec, SqlRepository
78
+
79
+ spec = QuerySpec(
80
+ filters=[FilterClause("status", "eq", "active")],
81
+ joins=[JoinClause(table=orders, on=users.c.id == orders.c.user_id)],
82
+ select_columns=[users.c.id, func.count(orders.c.id).label("orders_total")],
83
+ group_by=[users.c.id],
84
+ order_by=[users.c.id.asc()],
85
+ )
86
+ rows = SqlRepository(session, users).aggregate(spec)
87
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "muscles-sql"
7
+ version = "1.0.0"
8
+ description = "Muscles SQL data layer for repositories, migrations and AI-first workflow."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Denis B.", email = "denis@butko.info" }]
13
+ dependencies = [
14
+ "muscles>=1.0.0rc1,<2.0.0",
15
+ "SQLAlchemy>=2.0,<3.0",
16
+ "alembic>=1.13,<2.0",
17
+ "click>=8.1,<9.0",
18
+ "pydantic>=2.7,<3.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=8.0,<9.0",
24
+ ]
25
+
26
+ [project.scripts]
27
+ muscles-sql = "muscles_sql.cli:main"
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.setuptools]
33
+ package-dir = {"" = "src"}
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .config import DatabaseConfig
7
+ from .config import SqlConnectionConfig
8
+ from .connections import SqlConnectionRegistry, UnknownSqlConnection
9
+ from .engine import EngineManager
10
+ from .mapping import map_model
11
+ from .providers import SqlResourceGeneratorProvider
12
+ from .query import FilterClause, JoinClause, QuerySpec
13
+ from .repository import SqlRepository
14
+ from .uow import UnitOfWork
15
+
16
+ __all__ = [
17
+ "DatabaseConfig",
18
+ "SqlConnectionConfig",
19
+ "SqlConnectionRegistry",
20
+ "UnknownSqlConnection",
21
+ "EngineManager",
22
+ "map_model",
23
+ "SqlRepository",
24
+ "UnitOfWork",
25
+ "QuerySpec",
26
+ "FilterClause",
27
+ "JoinClause",
28
+ "SqlResourceGeneratorProvider",
29
+ ]
30
+
31
+
32
+ def __getattr__(name: str):
33
+ if name == "DatabaseConfig":
34
+ from .config import DatabaseConfig
35
+
36
+ return DatabaseConfig
37
+ if name == "SqlConnectionConfig":
38
+ from .config import SqlConnectionConfig
39
+
40
+ return SqlConnectionConfig
41
+ if name == "SqlConnectionRegistry":
42
+ from .connections import SqlConnectionRegistry
43
+
44
+ return SqlConnectionRegistry
45
+ if name == "UnknownSqlConnection":
46
+ from .connections import UnknownSqlConnection
47
+
48
+ return UnknownSqlConnection
49
+ if name == "EngineManager":
50
+ from .engine import EngineManager
51
+
52
+ return EngineManager
53
+ if name == "map_model":
54
+ from .mapping import map_model
55
+
56
+ return map_model
57
+ if name == "SqlRepository":
58
+ from .repository import SqlRepository
59
+
60
+ return SqlRepository
61
+ if name == "UnitOfWork":
62
+ from .uow import UnitOfWork
63
+
64
+ return UnitOfWork
65
+ if name == "QuerySpec":
66
+ from .query import QuerySpec
67
+
68
+ return QuerySpec
69
+ if name == "FilterClause":
70
+ from .query import FilterClause
71
+
72
+ return FilterClause
73
+ if name == "JoinClause":
74
+ from .query import JoinClause
75
+
76
+ return JoinClause
77
+ if name == "SqlResourceGeneratorProvider":
78
+ from .providers import SqlResourceGeneratorProvider
79
+
80
+ return SqlResourceGeneratorProvider
81
+ raise AttributeError(name)
@@ -0,0 +1,144 @@
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("migrate")
45
+ def migrate():
46
+ pass
47
+
48
+
49
+ @migrate.command("init")
50
+ @click.option("--dir", "target_dir", default="migrations")
51
+ def migrate_init(target_dir: str):
52
+ from .migrations import init_migrations
53
+
54
+ root = init_migrations(target_dir=target_dir)
55
+ click.echo(str(root))
56
+
57
+
58
+ @migrate.command("revision")
59
+ @click.option("--dir", "target_dir", default="migrations")
60
+ @click.option("--message", default="revision")
61
+ @click.option("--autogenerate", is_flag=True, default=False)
62
+ @click.option("--url", default=None)
63
+ def migrate_revision(target_dir: str, message: str, autogenerate: bool, url: str | None):
64
+ from .migrations import revision
65
+
66
+ revision(message=message, target_dir=target_dir, autogenerate=autogenerate, url=url)
67
+ click.echo("ok")
68
+
69
+
70
+ @migrate.command("upgrade")
71
+ @click.option("--dir", "target_dir", default="migrations")
72
+ @click.option("--rev", default="head")
73
+ @click.option("--url", default=None)
74
+ def migrate_upgrade(target_dir: str, rev: str, url: str | None):
75
+ from .migrations import upgrade
76
+
77
+ upgrade(rev=rev, target_dir=target_dir, url=url)
78
+ click.echo("ok")
79
+
80
+
81
+ @migrate.command("downgrade")
82
+ @click.option("--dir", "target_dir", default="migrations")
83
+ @click.option("--rev", default="-1")
84
+ @click.option("--url", default=None)
85
+ def migrate_downgrade(target_dir: str, rev: str, url: str | None):
86
+ from .migrations import downgrade
87
+
88
+ downgrade(rev=rev, target_dir=target_dir, url=url)
89
+ click.echo("ok")
90
+
91
+
92
+ @migrate.command("history")
93
+ @click.option("--dir", "target_dir", default="migrations")
94
+ @click.option("--url", default=None)
95
+ def migrate_history(target_dir: str, url: str | None):
96
+ from .migrations import history
97
+
98
+ history(target_dir=target_dir, url=url)
99
+ click.echo("ok")
100
+
101
+
102
+ @migrate.command("current")
103
+ @click.option("--dir", "target_dir", default="migrations")
104
+ @click.option("--url", default=None)
105
+ def migrate_current(target_dir: str, url: str | None):
106
+ from .migrations import current
107
+
108
+ current(target_dir=target_dir, url=url)
109
+ click.echo("ok")
110
+
111
+
112
+ @main.group("generate")
113
+ def generate():
114
+ pass
115
+
116
+
117
+ @generate.command("resource")
118
+ @click.argument("name")
119
+ @click.option("--sql", is_flag=True, default=False)
120
+ def generate_resource(name: str, sql: bool):
121
+ if sql:
122
+ provider = SqlResourceGeneratorProvider()
123
+ generated = provider.generate(
124
+ project_root=Path.cwd(),
125
+ request=SqlGenerationRequest(
126
+ generator_type="sql-resource",
127
+ name=name,
128
+ force=False,
129
+ with_tests=False,
130
+ ),
131
+ )
132
+ click.echo(generated[0])
133
+ return
134
+
135
+ root = Path("generated")
136
+ root.mkdir(exist_ok=True)
137
+ target = root / f"{name}.py"
138
+ content = f"class {name.title().replace('_', '')}Resource:\n sql_enabled = {sql}\n"
139
+ target.write_text(content, encoding="utf-8")
140
+ click.echo(str(target))
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
@@ -0,0 +1,46 @@
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
+ return make_url(self.url).render_as_string(hide_password=True)
30
+
31
+ def to_database_config(self) -> DatabaseConfig:
32
+ return DatabaseConfig(
33
+ url=self.url,
34
+ echo=self.echo,
35
+ pool_size=self.pool_size,
36
+ max_overflow=self.max_overflow,
37
+ future=self.future,
38
+ )
39
+
40
+ def to_contract(self) -> dict[str, Any]:
41
+ return {
42
+ "name": self.name,
43
+ "url": self.safe_url,
44
+ "role": self.role,
45
+ "metadata": dict(self.metadata),
46
+ }
@@ -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 .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,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
+ }