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.
@@ -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))
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ muscles-sql = muscles_sql.cli:main
@@ -0,0 +1 @@
1
+ muscles_sql