muscles-sql 1.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_sql/__init__.py +81 -0
- muscles_sql/cli.py +144 -0
- muscles_sql/config.py +46 -0
- muscles_sql/connections.py +105 -0
- muscles_sql/engine.py +19 -0
- muscles_sql/inspect.py +26 -0
- muscles_sql/mapping.py +71 -0
- muscles_sql/migrations.py +132 -0
- muscles_sql/providers.py +30 -0
- muscles_sql/query.py +33 -0
- muscles_sql/repository.py +227 -0
- muscles_sql/uow.py +47 -0
- muscles_sql-1.0.0.dist-info/METADATA +103 -0
- muscles_sql-1.0.0.dist-info/RECORD +17 -0
- muscles_sql-1.0.0.dist-info/WHEEL +5 -0
- muscles_sql-1.0.0.dist-info/entry_points.txt +2 -0
- muscles_sql-1.0.0.dist-info/top_level.txt +1 -0
muscles_sql/__init__.py
ADDED
|
@@ -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)
|
muscles_sql/cli.py
ADDED
|
@@ -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()
|
muscles_sql/config.py
ADDED
|
@@ -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)
|
muscles_sql/engine.py
ADDED
|
@@ -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()
|
muscles_sql/inspect.py
ADDED
|
@@ -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
|
+
}
|
muscles_sql/mapping.py
ADDED
|
@@ -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,132 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def init_migrations(target_dir: str = "migrations") -> Path:
|
|
7
|
+
root = Path(target_dir)
|
|
8
|
+
versions = root / "versions"
|
|
9
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
versions.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
_ensure_bootstrap_files(root)
|
|
12
|
+
return root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_bootstrap_files(root: Path) -> None:
|
|
16
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
env_py = root / "env.py"
|
|
18
|
+
script_mako = root / "script.py.mako"
|
|
19
|
+
readme = root / "README"
|
|
20
|
+
if not env_py.exists():
|
|
21
|
+
env_py.write_text(
|
|
22
|
+
"from __future__ import annotations\n"
|
|
23
|
+
"from alembic import context\n"
|
|
24
|
+
"from sqlalchemy import engine_from_config, pool\n\n"
|
|
25
|
+
"config = context.config\n"
|
|
26
|
+
"target_metadata = config.attributes.get('target_metadata')\n\n"
|
|
27
|
+
"def run_migrations_offline():\n"
|
|
28
|
+
" url = config.get_main_option('sqlalchemy.url')\n"
|
|
29
|
+
" context.configure(url=url, target_metadata=target_metadata, literal_binds=True)\n"
|
|
30
|
+
" with context.begin_transaction():\n"
|
|
31
|
+
" context.run_migrations()\n\n"
|
|
32
|
+
"def run_migrations_online():\n"
|
|
33
|
+
" connectable = engine_from_config(config.get_section(config.config_ini_section) or {}, prefix='sqlalchemy.', poolclass=pool.NullPool)\n"
|
|
34
|
+
" with connectable.connect() as connection:\n"
|
|
35
|
+
" context.configure(connection=connection, target_metadata=target_metadata)\n"
|
|
36
|
+
" with context.begin_transaction():\n"
|
|
37
|
+
" context.run_migrations()\n\n"
|
|
38
|
+
"if context.is_offline_mode():\n"
|
|
39
|
+
" run_migrations_offline()\n"
|
|
40
|
+
"else:\n"
|
|
41
|
+
" run_migrations_online()\n",
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
if not script_mako.exists():
|
|
45
|
+
script_mako.write_text(
|
|
46
|
+
"\"\"\"${message}\n\n"
|
|
47
|
+
"Revision ID: ${up_revision}\n"
|
|
48
|
+
"Revises: ${down_revision | comma,n}\n"
|
|
49
|
+
"Create Date: ${create_date}\n"
|
|
50
|
+
"\"\"\"\n\n"
|
|
51
|
+
"from alembic import op\n"
|
|
52
|
+
"import sqlalchemy as sa\n\n"
|
|
53
|
+
"revision = ${repr(up_revision)}\n"
|
|
54
|
+
"down_revision = ${repr(down_revision)}\n"
|
|
55
|
+
"branch_labels = ${repr(branch_labels)}\n"
|
|
56
|
+
"depends_on = ${repr(depends_on)}\n\n"
|
|
57
|
+
"def upgrade():\n"
|
|
58
|
+
" pass\n\n"
|
|
59
|
+
"def downgrade():\n"
|
|
60
|
+
" pass\n",
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
if not readme.exists():
|
|
64
|
+
readme.write_text("Muscles SQL Alembic migration environment.\n", encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_alembic():
|
|
68
|
+
try:
|
|
69
|
+
from alembic import command
|
|
70
|
+
from alembic.config import Config
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise RuntimeError("Alembic is required for migration commands") from exc
|
|
73
|
+
return command, Config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_url(url: str | None = None) -> str | None:
|
|
77
|
+
if url:
|
|
78
|
+
return url
|
|
79
|
+
env_url = os.getenv("MUSCLES_SQL_URL")
|
|
80
|
+
if env_url:
|
|
81
|
+
return env_url
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _config(target_dir: str = "migrations", url: str | None = None, target_metadata: Any | None = None):
|
|
86
|
+
_, Config = _load_alembic()
|
|
87
|
+
root = Path(target_dir)
|
|
88
|
+
_ensure_bootstrap_files(root)
|
|
89
|
+
cfg = Config()
|
|
90
|
+
cfg.set_main_option("script_location", target_dir)
|
|
91
|
+
resolved_url = _resolve_url(url)
|
|
92
|
+
if not resolved_url:
|
|
93
|
+
raise ValueError("Database URL is required for migration commands")
|
|
94
|
+
cfg.set_main_option("sqlalchemy.url", resolved_url)
|
|
95
|
+
if target_metadata is not None:
|
|
96
|
+
cfg.attributes["target_metadata"] = target_metadata
|
|
97
|
+
return cfg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def revision(
|
|
101
|
+
message: str,
|
|
102
|
+
target_dir: str = "migrations",
|
|
103
|
+
autogenerate: bool = False,
|
|
104
|
+
url: str | None = None,
|
|
105
|
+
target_metadata: Any | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
command, _ = _load_alembic()
|
|
108
|
+
command.revision(
|
|
109
|
+
_config(target_dir, url=url, target_metadata=target_metadata),
|
|
110
|
+
message=message,
|
|
111
|
+
autogenerate=autogenerate,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def upgrade(rev: str = "head", target_dir: str = "migrations", url: str | None = None) -> None:
|
|
116
|
+
command, _ = _load_alembic()
|
|
117
|
+
command.upgrade(_config(target_dir, url=url), rev)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def downgrade(rev: str = "-1", target_dir: str = "migrations", url: str | None = None) -> None:
|
|
121
|
+
command, _ = _load_alembic()
|
|
122
|
+
command.downgrade(_config(target_dir, url=url), rev)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def history(target_dir: str = "migrations", url: str | None = None) -> None:
|
|
126
|
+
command, _ = _load_alembic()
|
|
127
|
+
command.history(_config(target_dir, url=url))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def current(target_dir: str = "migrations", url: str | None = None) -> None:
|
|
131
|
+
command, _ = _load_alembic()
|
|
132
|
+
command.current(_config(target_dir, url=url))
|
muscles_sql/providers.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SqlGenerationRequest:
|
|
9
|
+
generator_type: str
|
|
10
|
+
name: str
|
|
11
|
+
force: bool = False
|
|
12
|
+
with_tests: bool = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SqlResourceGeneratorProvider:
|
|
16
|
+
name = "muscles-sql.resource"
|
|
17
|
+
|
|
18
|
+
def supports(self, generator_type: str) -> bool:
|
|
19
|
+
return generator_type in {"sql-resource", "resource-sql"}
|
|
20
|
+
|
|
21
|
+
def generate(self, project_root: Path, request: SqlGenerationRequest) -> list[str]:
|
|
22
|
+
root = project_root / "generated"
|
|
23
|
+
root.mkdir(exist_ok=True)
|
|
24
|
+
target = root / f"{request.name}.py"
|
|
25
|
+
content = (
|
|
26
|
+
f"class {request.name.title().replace('_', '')}Resource:\n"
|
|
27
|
+
f" sql_enabled = True\n"
|
|
28
|
+
)
|
|
29
|
+
target.write_text(content, encoding="utf-8")
|
|
30
|
+
return [str(target)]
|
muscles_sql/query.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class FilterClause:
|
|
9
|
+
field: str
|
|
10
|
+
op: str
|
|
11
|
+
value: Any = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class JoinClause:
|
|
16
|
+
table: Any
|
|
17
|
+
on: Any
|
|
18
|
+
is_outer: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class QuerySpec:
|
|
23
|
+
filters: list[FilterClause] = field(default_factory=list)
|
|
24
|
+
or_filters: list[FilterClause] = field(default_factory=list)
|
|
25
|
+
joins: list[JoinClause] = field(default_factory=list)
|
|
26
|
+
select_columns: list[Any] = field(default_factory=list)
|
|
27
|
+
order_by: list[Any] = field(default_factory=list)
|
|
28
|
+
group_by: list[Any] = field(default_factory=list)
|
|
29
|
+
having: list[Any] = field(default_factory=list)
|
|
30
|
+
limit: int | None = 100
|
|
31
|
+
offset: int = 0
|
|
32
|
+
distinct: bool = False
|
|
33
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .query import FilterClause, JoinClause, QuerySpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlRepository:
|
|
9
|
+
def __init__(self, session, table):
|
|
10
|
+
self.session = session
|
|
11
|
+
self.table = table
|
|
12
|
+
|
|
13
|
+
def create(self, data: dict):
|
|
14
|
+
from sqlalchemy import insert
|
|
15
|
+
|
|
16
|
+
result = self.session.execute(insert(self.table).values(**data))
|
|
17
|
+
return result.inserted_primary_key
|
|
18
|
+
|
|
19
|
+
def get(self, pk):
|
|
20
|
+
from sqlalchemy import select
|
|
21
|
+
|
|
22
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
23
|
+
stmt = select(self.table).where(pk_col == pk)
|
|
24
|
+
return self.session.execute(stmt).mappings().first()
|
|
25
|
+
|
|
26
|
+
def list(self, limit: int = 100, offset: int = 0, order_by: Any = None):
|
|
27
|
+
from sqlalchemy import select
|
|
28
|
+
|
|
29
|
+
stmt = select(self.table).limit(limit).offset(offset)
|
|
30
|
+
if order_by is not None:
|
|
31
|
+
stmt = stmt.order_by(order_by)
|
|
32
|
+
return self.session.execute(stmt).mappings().all()
|
|
33
|
+
|
|
34
|
+
def find(self, filters: dict | None = None, limit: int = 100, offset: int = 0, order_by: Any = None):
|
|
35
|
+
from sqlalchemy import select
|
|
36
|
+
|
|
37
|
+
clauses = [FilterClause(field=field, op="eq", value=value) for field, value in (filters or {}).items()]
|
|
38
|
+
spec = QuerySpec(filters=clauses, limit=limit, offset=offset, order_by=[order_by] if order_by is not None else [])
|
|
39
|
+
stmt = self._build_select(spec)
|
|
40
|
+
return self.session.execute(stmt).mappings().all()
|
|
41
|
+
|
|
42
|
+
def count(self, filters: dict | None = None) -> int:
|
|
43
|
+
from sqlalchemy import func, select
|
|
44
|
+
|
|
45
|
+
stmt = select(func.count()).select_from(self.table)
|
|
46
|
+
filters = filters or {}
|
|
47
|
+
for field, value in filters.items():
|
|
48
|
+
stmt = stmt.where(getattr(self.table.c, field) == value)
|
|
49
|
+
return int(self.session.execute(stmt).scalar_one())
|
|
50
|
+
|
|
51
|
+
def exists(self, filters: dict) -> bool:
|
|
52
|
+
return self.count(filters) > 0
|
|
53
|
+
|
|
54
|
+
def update(self, pk, data: dict):
|
|
55
|
+
from sqlalchemy import update
|
|
56
|
+
|
|
57
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
58
|
+
stmt = update(self.table).where(pk_col == pk).values(**data)
|
|
59
|
+
return self.session.execute(stmt).rowcount
|
|
60
|
+
|
|
61
|
+
def delete(self, pk):
|
|
62
|
+
from sqlalchemy import delete
|
|
63
|
+
|
|
64
|
+
pk_col = list(self.table.primary_key.columns)[0]
|
|
65
|
+
stmt = delete(self.table).where(pk_col == pk)
|
|
66
|
+
return self.session.execute(stmt).rowcount
|
|
67
|
+
|
|
68
|
+
def query(self, spec: QuerySpec):
|
|
69
|
+
stmt = self._build_select(spec)
|
|
70
|
+
return self.session.execute(stmt).mappings().all()
|
|
71
|
+
|
|
72
|
+
def first(self, spec: QuerySpec):
|
|
73
|
+
first_spec = QuerySpec(
|
|
74
|
+
filters=list(spec.filters),
|
|
75
|
+
or_filters=list(spec.or_filters),
|
|
76
|
+
joins=list(spec.joins),
|
|
77
|
+
select_columns=list(spec.select_columns),
|
|
78
|
+
order_by=list(spec.order_by),
|
|
79
|
+
group_by=list(spec.group_by),
|
|
80
|
+
having=list(spec.having),
|
|
81
|
+
limit=1,
|
|
82
|
+
offset=spec.offset,
|
|
83
|
+
distinct=spec.distinct,
|
|
84
|
+
)
|
|
85
|
+
stmt = self._build_select(first_spec)
|
|
86
|
+
return self.session.execute(stmt).mappings().first()
|
|
87
|
+
|
|
88
|
+
def scalar(self, spec: QuerySpec):
|
|
89
|
+
stmt = self._build_select(spec)
|
|
90
|
+
return self.session.execute(stmt).scalar()
|
|
91
|
+
|
|
92
|
+
def aggregate(self, spec: QuerySpec):
|
|
93
|
+
stmt = self._build_select(spec)
|
|
94
|
+
return self.session.execute(stmt).mappings().all()
|
|
95
|
+
|
|
96
|
+
def bulk_create(self, rows: list[dict]) -> int:
|
|
97
|
+
from sqlalchemy import insert
|
|
98
|
+
|
|
99
|
+
if not rows:
|
|
100
|
+
return 0
|
|
101
|
+
self.session.execute(insert(self.table), rows)
|
|
102
|
+
return len(rows)
|
|
103
|
+
|
|
104
|
+
def bulk_update(self, pk_field: str, rows: list[dict]) -> int:
|
|
105
|
+
if not rows:
|
|
106
|
+
return 0
|
|
107
|
+
updated = 0
|
|
108
|
+
for row in rows:
|
|
109
|
+
pk = row.get(pk_field)
|
|
110
|
+
if pk is None:
|
|
111
|
+
continue
|
|
112
|
+
data = {k: v for k, v in row.items() if k != pk_field}
|
|
113
|
+
if not data:
|
|
114
|
+
continue
|
|
115
|
+
updated += int(self.update(pk, data) or 0)
|
|
116
|
+
return updated
|
|
117
|
+
|
|
118
|
+
def upsert(self, row: dict, conflict_fields: list[str]):
|
|
119
|
+
from sqlalchemy import inspect
|
|
120
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
121
|
+
from sqlalchemy.exc import OperationalError
|
|
122
|
+
|
|
123
|
+
if not self._has_unique_conflict_constraint(conflict_fields):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"conflict_fields must match a PRIMARY KEY or UNIQUE constraint for safe upsert fallback"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
dialect_name = inspect(self.session.bind).dialect.name
|
|
129
|
+
if dialect_name == "sqlite":
|
|
130
|
+
stmt = sqlite_insert(self.table).values(**row)
|
|
131
|
+
update_data = {k: v for k, v in row.items() if k not in conflict_fields}
|
|
132
|
+
stmt = stmt.on_conflict_do_update(index_elements=conflict_fields, set_=update_data)
|
|
133
|
+
try:
|
|
134
|
+
self.session.execute(stmt)
|
|
135
|
+
return 1
|
|
136
|
+
except OperationalError:
|
|
137
|
+
# Fallback for tables where conflict_fields are not backed by unique/pk constraints.
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
filters = {field: row[field] for field in conflict_fields if field in row}
|
|
141
|
+
if filters:
|
|
142
|
+
existing = self.find(filters=filters, limit=1)
|
|
143
|
+
if existing:
|
|
144
|
+
pk_col = list(self.table.primary_key.columns)[0].name
|
|
145
|
+
pk_val = existing[0][pk_col]
|
|
146
|
+
return self.update(pk_val, {k: v for k, v in row.items() if k != pk_col})
|
|
147
|
+
|
|
148
|
+
pk_col = list(self.table.primary_key.columns)[0].name
|
|
149
|
+
pk_val = row.get(pk_col)
|
|
150
|
+
if pk_val is not None and self.get(pk_val):
|
|
151
|
+
return self.update(pk_val, {k: v for k, v in row.items() if k != pk_col})
|
|
152
|
+
self.create(row)
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
def _has_unique_conflict_constraint(self, conflict_fields: list[str]) -> bool:
|
|
156
|
+
if not conflict_fields:
|
|
157
|
+
return False
|
|
158
|
+
conflict_set = set(conflict_fields)
|
|
159
|
+
pk_cols = {column.name for column in self.table.primary_key.columns}
|
|
160
|
+
if conflict_set == pk_cols:
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
for constraint in getattr(self.table, "constraints", []):
|
|
164
|
+
columns = getattr(constraint, "columns", None)
|
|
165
|
+
if columns is None:
|
|
166
|
+
continue
|
|
167
|
+
if getattr(constraint, "unique", False) and {column.name for column in columns} == conflict_set:
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
for index in getattr(self.table, "indexes", []):
|
|
171
|
+
if getattr(index, "unique", False) and {column.name for column in index.columns} == conflict_set:
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
def _build_select(self, spec: QuerySpec):
|
|
177
|
+
from sqlalchemy import and_, or_, select
|
|
178
|
+
|
|
179
|
+
columns = spec.select_columns or [self.table]
|
|
180
|
+
stmt = select(*columns)
|
|
181
|
+
if spec.distinct:
|
|
182
|
+
stmt = stmt.distinct()
|
|
183
|
+
|
|
184
|
+
for join in spec.joins:
|
|
185
|
+
if join.is_outer:
|
|
186
|
+
stmt = stmt.outerjoin(join.table, join.on)
|
|
187
|
+
else:
|
|
188
|
+
stmt = stmt.join(join.table, join.on)
|
|
189
|
+
|
|
190
|
+
if spec.filters:
|
|
191
|
+
stmt = stmt.where(and_(*[self._build_filter_expr(clause) for clause in spec.filters]))
|
|
192
|
+
if spec.or_filters:
|
|
193
|
+
stmt = stmt.where(or_(*[self._build_filter_expr(clause) for clause in spec.or_filters]))
|
|
194
|
+
if spec.group_by:
|
|
195
|
+
stmt = stmt.group_by(*spec.group_by)
|
|
196
|
+
if spec.having:
|
|
197
|
+
stmt = stmt.having(*spec.having)
|
|
198
|
+
if spec.order_by:
|
|
199
|
+
stmt = stmt.order_by(*spec.order_by)
|
|
200
|
+
if spec.limit is not None:
|
|
201
|
+
stmt = stmt.limit(spec.limit)
|
|
202
|
+
if spec.offset:
|
|
203
|
+
stmt = stmt.offset(spec.offset)
|
|
204
|
+
return stmt
|
|
205
|
+
|
|
206
|
+
def _build_filter_expr(self, clause: FilterClause):
|
|
207
|
+
column = getattr(self.table.c, clause.field)
|
|
208
|
+
op = clause.op.lower()
|
|
209
|
+
if op == "eq":
|
|
210
|
+
return column == clause.value
|
|
211
|
+
if op == "ne":
|
|
212
|
+
return column != clause.value
|
|
213
|
+
if op == "gt":
|
|
214
|
+
return column > clause.value
|
|
215
|
+
if op == "gte":
|
|
216
|
+
return column >= clause.value
|
|
217
|
+
if op == "lt":
|
|
218
|
+
return column < clause.value
|
|
219
|
+
if op == "lte":
|
|
220
|
+
return column <= clause.value
|
|
221
|
+
if op == "in":
|
|
222
|
+
return column.in_(clause.value or [])
|
|
223
|
+
if op == "like":
|
|
224
|
+
return column.like(clause.value)
|
|
225
|
+
if op == "is_null":
|
|
226
|
+
return column.is_(None) if clause.value else column.is_not(None)
|
|
227
|
+
raise ValueError(f"Unsupported filter operator: {clause.op}")
|
muscles_sql/uow.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnitOfWork:
|
|
5
|
+
def __init__(self, session_factory, *, isolation_level: str | None = None):
|
|
6
|
+
self._session_factory = session_factory
|
|
7
|
+
self._isolation_level = isolation_level
|
|
8
|
+
self.session: Any | None = None
|
|
9
|
+
|
|
10
|
+
def _require_session(self):
|
|
11
|
+
if self.session is None:
|
|
12
|
+
raise RuntimeError("UnitOfWork requires an active session; use it as a context manager first.")
|
|
13
|
+
return self.session
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
session = self._session_factory()
|
|
17
|
+
if self._isolation_level:
|
|
18
|
+
session.connection(execution_options={"isolation_level": self._isolation_level})
|
|
19
|
+
self.session = session
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, exc_type, exc, tb):
|
|
23
|
+
session = self._require_session()
|
|
24
|
+
if exc_type is None:
|
|
25
|
+
session.commit()
|
|
26
|
+
else:
|
|
27
|
+
session.rollback()
|
|
28
|
+
session.close()
|
|
29
|
+
self.session = None
|
|
30
|
+
|
|
31
|
+
def begin_nested(self):
|
|
32
|
+
return self._require_session().begin_nested()
|
|
33
|
+
|
|
34
|
+
def with_retry(self, fn, *, max_attempts: int = 3, retry_on: tuple[type[Exception], ...] = ()):
|
|
35
|
+
if max_attempts < 1:
|
|
36
|
+
raise ValueError("max_attempts must be >= 1")
|
|
37
|
+
attempt = 0
|
|
38
|
+
while True:
|
|
39
|
+
attempt += 1
|
|
40
|
+
session = self._require_session()
|
|
41
|
+
try:
|
|
42
|
+
return fn(session)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
if retry_on and isinstance(exc, retry_on) and attempt < max_attempts:
|
|
45
|
+
session.rollback()
|
|
46
|
+
continue
|
|
47
|
+
raise
|
|
@@ -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,17 @@
|
|
|
1
|
+
muscles_sql/__init__.py,sha256=ykLLjAorsdniKIuCLvD7jXAPtIa1isN5Df5WgVzuhTw,2182
|
|
2
|
+
muscles_sql/cli.py,sha256=aIHvZJ3ocxxxv1AejoiQC71ott5RcQfTvL-MQYAawno,4550
|
|
3
|
+
muscles_sql/config.py,sha256=0z_GyprgaJxEHLuYSpqsuLtj2BO861_SuzEvmKD0pig,1125
|
|
4
|
+
muscles_sql/connections.py,sha256=Q2VuBLimVWKjPNrrtQVg4JC_1Me3mCO-fwguWprvYsk,3921
|
|
5
|
+
muscles_sql/engine.py,sha256=sA7LJOyYAOkwE9N5KEESmWiZ2pVwPZEjmBwLLgQFqN0,690
|
|
6
|
+
muscles_sql/inspect.py,sha256=-o1vDXE4o72WEHhXd0uTQWov7LZ_wEbLLesjz41Sp6I,1005
|
|
7
|
+
muscles_sql/mapping.py,sha256=N9UbtpD7LdP38pQ2jHlSx64uPuWWYA2uMWo0Bv6EAAE,2101
|
|
8
|
+
muscles_sql/migrations.py,sha256=d7Kp9nB39R_bpTJ53rqrwXcx_BHjhN8C2dlObeoCjss,4757
|
|
9
|
+
muscles_sql/providers.py,sha256=9IOIE-UK5ZlRo22n_YxhyTuxx9yryhK6cJUgQ0DkuBM,866
|
|
10
|
+
muscles_sql/query.py,sha256=yroYWdq0OiwMdeZqfANPZtwK7u0pCvr6OX64QWmK_ts,817
|
|
11
|
+
muscles_sql/repository.py,sha256=40oMelQG-dV9beAvm6jTEUnYRe28myRXQc5nKr_uovA,8512
|
|
12
|
+
muscles_sql/uow.py,sha256=rVTZkwCkbG6HRSQaiEYTDel80tUkRRdhZPh6tlySGRc,1595
|
|
13
|
+
muscles_sql-1.0.0.dist-info/METADATA,sha256=VjOhbxyx8TdAZ8LFZhbI6CDTiaFZ63H4nxCux-FqtRw,3311
|
|
14
|
+
muscles_sql-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
15
|
+
muscles_sql-1.0.0.dist-info/entry_points.txt,sha256=-DFmYLPEOFdKfRd5LX6dNmPTtdjliQKUxeIBGiuRRFk,53
|
|
16
|
+
muscles_sql-1.0.0.dist-info/top_level.txt,sha256=FqDIvlpbA1v3ly7oYMppc8psf_8Z2zeS2O9g--ZpGmk,12
|
|
17
|
+
muscles_sql-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles_sql
|