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.
- muscles_sql-1.0.0/PKG-INFO +103 -0
- muscles_sql-1.0.0/README.md +87 -0
- muscles_sql-1.0.0/pyproject.toml +36 -0
- muscles_sql-1.0.0/setup.cfg +4 -0
- muscles_sql-1.0.0/src/muscles_sql/__init__.py +81 -0
- muscles_sql-1.0.0/src/muscles_sql/cli.py +144 -0
- muscles_sql-1.0.0/src/muscles_sql/config.py +46 -0
- muscles_sql-1.0.0/src/muscles_sql/connections.py +105 -0
- muscles_sql-1.0.0/src/muscles_sql/engine.py +19 -0
- muscles_sql-1.0.0/src/muscles_sql/inspect.py +26 -0
- muscles_sql-1.0.0/src/muscles_sql/mapping.py +71 -0
- muscles_sql-1.0.0/src/muscles_sql/migrations.py +132 -0
- muscles_sql-1.0.0/src/muscles_sql/providers.py +30 -0
- muscles_sql-1.0.0/src/muscles_sql/query.py +33 -0
- muscles_sql-1.0.0/src/muscles_sql/repository.py +227 -0
- muscles_sql-1.0.0/src/muscles_sql/uow.py +47 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/PKG-INFO +103 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/SOURCES.txt +28 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/dependency_links.txt +1 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/entry_points.txt +2 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/requires.txt +8 -0
- muscles_sql-1.0.0/src/muscles_sql.egg-info/top_level.txt +1 -0
- muscles_sql-1.0.0/tests/test_cli.py +51 -0
- muscles_sql-1.0.0/tests/test_connections.py +89 -0
- muscles_sql-1.0.0/tests/test_di_core_compat_smoke.py +17 -0
- muscles_sql-1.0.0/tests/test_engine_and_inspect.py +12 -0
- muscles_sql-1.0.0/tests/test_lazy_imports.py +43 -0
- muscles_sql-1.0.0/tests/test_mapping.py +52 -0
- muscles_sql-1.0.0/tests/test_migrations.py +48 -0
- 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,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
|
+
}
|