muscles-data-sql 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muscles/data/sql/__init__.py +114 -0
- muscles/data/sql/cli.py +76 -0
- muscles/data/sql/config.py +50 -0
- muscles/data/sql/connections.py +105 -0
- muscles/data/sql/data_adapter.py +71 -0
- muscles/data/sql/drivers.py +52 -0
- muscles/data/sql/engine.py +19 -0
- muscles/data/sql/inspect.py +26 -0
- muscles/data/sql/mapping.py +71 -0
- muscles/data/sql/migration_lock.py +53 -0
- muscles/data/sql/model_mapper.py +30 -0
- muscles/data/sql/native.py +131 -0
- muscles/data/sql/native_migrations.py +200 -0
- muscles/data/sql/providers.py +30 -0
- muscles/data/sql/query.py +33 -0
- muscles/data/sql/repository.py +438 -0
- muscles/data/sql/sqlalchemy_compat.py +18 -0
- muscles/data/sql/uow.py +47 -0
- muscles_data_sql-2.0.0.dist-info/METADATA +223 -0
- muscles_data_sql-2.0.0.dist-info/RECORD +23 -0
- muscles_data_sql-2.0.0.dist-info/WHEEL +5 -0
- muscles_data_sql-2.0.0.dist-info/entry_points.txt +2 -0
- muscles_data_sql-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Small DB-API based SQL access without an ORM or query builder."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import Lock, get_ident
|
|
7
|
+
from typing import Any, ContextManager, Iterator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SqlTransaction:
|
|
11
|
+
def __init__(self, connection: Any):
|
|
12
|
+
self._connection = connection
|
|
13
|
+
self._active = True
|
|
14
|
+
self._owner = get_ident()
|
|
15
|
+
|
|
16
|
+
def execute(self, sql: str, parameters: Any = None) -> int:
|
|
17
|
+
with self._cursor() as cursor:
|
|
18
|
+
self._execute(cursor, sql, parameters)
|
|
19
|
+
return cursor.rowcount
|
|
20
|
+
|
|
21
|
+
def fetch_all(self, sql: str, parameters: Any = None) -> list[dict[str, Any]]:
|
|
22
|
+
with self._cursor() as cursor:
|
|
23
|
+
self._execute(cursor, sql, parameters)
|
|
24
|
+
names = self._column_names(cursor)
|
|
25
|
+
return [dict(zip(names, row)) for row in cursor.fetchall()]
|
|
26
|
+
|
|
27
|
+
def fetch_one(self, sql: str, parameters: Any = None) -> dict[str, Any] | None:
|
|
28
|
+
with self._cursor() as cursor:
|
|
29
|
+
self._execute(cursor, sql, parameters)
|
|
30
|
+
row = cursor.fetchone()
|
|
31
|
+
names = self._column_names(cursor)
|
|
32
|
+
return None if row is None else dict(zip(names, row))
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _execute(cursor, sql, parameters):
|
|
36
|
+
if parameters is None:
|
|
37
|
+
cursor.execute(sql)
|
|
38
|
+
else:
|
|
39
|
+
cursor.execute(sql, parameters)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _column_names(cursor):
|
|
43
|
+
names = [column[0] for column in cursor.description]
|
|
44
|
+
if len(names) != len(set(names)):
|
|
45
|
+
raise ValueError("SQL result contains duplicate column names; use aliases")
|
|
46
|
+
return names
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _cursor(self):
|
|
50
|
+
if not self._active:
|
|
51
|
+
raise RuntimeError("SQL transaction is no longer active")
|
|
52
|
+
if self._owner != get_ident():
|
|
53
|
+
raise RuntimeError("SQL transaction belongs to a different thread")
|
|
54
|
+
cursor = self._connection.cursor()
|
|
55
|
+
try:
|
|
56
|
+
yield cursor
|
|
57
|
+
finally:
|
|
58
|
+
cursor.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SqlDatabase:
|
|
62
|
+
"""Own one connection; transactions commit on success and roll back on error."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, connection: Any, *, begin_sql: str | None = None, health_query: str = "SELECT 1 AS value"):
|
|
65
|
+
self._connection = connection
|
|
66
|
+
self._begin_sql = begin_sql
|
|
67
|
+
self._health_query = health_query
|
|
68
|
+
self._lock = Lock()
|
|
69
|
+
self._transaction_owner: int | None = None
|
|
70
|
+
self._closed = False
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def sqlite(cls, path: str | Path) -> SqlDatabase:
|
|
74
|
+
import sqlite3
|
|
75
|
+
|
|
76
|
+
return cls(sqlite3.connect(str(path), check_same_thread=False), begin_sql="BEGIN")
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def postgres(cls, dsn: str) -> SqlDatabase:
|
|
80
|
+
try:
|
|
81
|
+
import psycopg
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
raise ImportError("PostgreSQL requires muscles-data-sql[postgres]") from exc
|
|
84
|
+
normalized_dsn = dsn.replace("postgresql+psycopg2://", "postgresql://", 1).replace(
|
|
85
|
+
"postgresql+psycopg://", "postgresql://", 1
|
|
86
|
+
)
|
|
87
|
+
return cls(psycopg.connect(normalized_dsn))
|
|
88
|
+
|
|
89
|
+
def transaction(self) -> ContextManager[SqlTransaction]:
|
|
90
|
+
return self._transaction(self._begin_sql)
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def _transaction(self, begin_sql: str | None) -> Iterator[SqlTransaction]:
|
|
94
|
+
owner = get_ident()
|
|
95
|
+
if self._transaction_owner == owner:
|
|
96
|
+
raise RuntimeError("Nested SQL transactions are not supported")
|
|
97
|
+
self._lock.acquire()
|
|
98
|
+
self._transaction_owner = owner
|
|
99
|
+
try:
|
|
100
|
+
if self._closed:
|
|
101
|
+
raise RuntimeError("SQL database is closed")
|
|
102
|
+
transaction = SqlTransaction(self._connection)
|
|
103
|
+
try:
|
|
104
|
+
if begin_sql is not None:
|
|
105
|
+
with transaction._cursor() as cursor:
|
|
106
|
+
cursor.execute(begin_sql)
|
|
107
|
+
yield transaction
|
|
108
|
+
self._connection.commit()
|
|
109
|
+
except BaseException:
|
|
110
|
+
self._connection.rollback()
|
|
111
|
+
raise
|
|
112
|
+
finally:
|
|
113
|
+
transaction._active = False
|
|
114
|
+
finally:
|
|
115
|
+
self._transaction_owner = None
|
|
116
|
+
self._lock.release()
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
if self._transaction_owner == get_ident():
|
|
120
|
+
raise RuntimeError("Cannot close a database during a transaction")
|
|
121
|
+
self._lock.acquire()
|
|
122
|
+
try:
|
|
123
|
+
if not self._closed:
|
|
124
|
+
self._connection.close()
|
|
125
|
+
self._closed = True
|
|
126
|
+
finally:
|
|
127
|
+
self._lock.release()
|
|
128
|
+
|
|
129
|
+
def check_health(self) -> None:
|
|
130
|
+
with self.transaction() as transaction:
|
|
131
|
+
transaction.fetch_one(self._health_query)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Transactional, explicitly registered SQLite and PostgreSQL migrations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Callable
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
|
|
12
|
+
from .native import SqlDatabase, SqlTransaction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class NativeSqlMigration:
|
|
17
|
+
revision: str
|
|
18
|
+
checksum: str
|
|
19
|
+
upgrade: Callable[[SqlTransaction], None]
|
|
20
|
+
downgrade: Callable[[SqlTransaction], None] | None = None
|
|
21
|
+
dialects: tuple[str, ...] = ()
|
|
22
|
+
source_path: str | None = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_module(cls, module: ModuleType) -> NativeSqlMigration:
|
|
26
|
+
path = getattr(module, "__file__", None)
|
|
27
|
+
if not path or not str(path).endswith(".py"):
|
|
28
|
+
raise ValueError("Migration must be backed by a Python source file")
|
|
29
|
+
checksum = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
30
|
+
loaded_checksum = getattr(module, "__muscles_migration_checksum__", checksum)
|
|
31
|
+
if loaded_checksum != checksum:
|
|
32
|
+
raise ValueError("Migration file changed after module discovery")
|
|
33
|
+
return cls(module.revision, checksum,
|
|
34
|
+
module.upgrade, getattr(module, "downgrade", None), getattr(module, "dialects", ()), str(path))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NativeSqlMigrationBackend:
|
|
38
|
+
"""Run a source atomically; only SQLite and PostgreSQL transactional SQL are supported."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, db: SqlDatabase, source: str, migrations: list[NativeSqlMigration], *, dialect: str):
|
|
41
|
+
if dialect not in {"sqlite", "postgres"}:
|
|
42
|
+
raise ValueError("Native migrations support only sqlite and postgres")
|
|
43
|
+
if not isinstance(source, str) or not source:
|
|
44
|
+
raise ValueError("Migration source is required")
|
|
45
|
+
if any(not isinstance(item, NativeSqlMigration) or not isinstance(item.revision, str)
|
|
46
|
+
or not item.revision or not isinstance(item.checksum, str) or not item.checksum
|
|
47
|
+
or not callable(item.upgrade) or (item.downgrade is not None and not callable(item.downgrade))
|
|
48
|
+
for item in migrations):
|
|
49
|
+
raise ValueError("Migrations must have unique ordered revisions, checksums and callable operations")
|
|
50
|
+
revisions = [item.revision for item in migrations]
|
|
51
|
+
if revisions != sorted(set(revisions)):
|
|
52
|
+
raise ValueError("Migrations must have unique ordered revisions, checksums and callable operations")
|
|
53
|
+
if any(not isinstance(item.dialects, tuple) or dialect not in item.dialects for item in migrations):
|
|
54
|
+
raise ValueError("Every migration must explicitly support the selected SQL dialect")
|
|
55
|
+
self.db, self.source, self.migrations, self.dialect = db, source, tuple(migrations), dialect
|
|
56
|
+
self._param = "?" if dialect == "sqlite" else "%s"
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def _write_transaction(self):
|
|
60
|
+
begin = "BEGIN IMMEDIATE" if self.dialect == "sqlite" else self.db._begin_sql
|
|
61
|
+
with self.db._transaction(begin) as tx:
|
|
62
|
+
if self.dialect == "postgres":
|
|
63
|
+
row = tx.fetch_one("SELECT pg_try_advisory_xact_lock(%s) AS locked", (778212473,))
|
|
64
|
+
if not row or not row["locked"]:
|
|
65
|
+
raise RuntimeError("SQL migrations are locked by another process")
|
|
66
|
+
yield tx
|
|
67
|
+
|
|
68
|
+
def _state(self, tx: SqlTransaction) -> list[str]:
|
|
69
|
+
for migration in self.migrations:
|
|
70
|
+
if migration.source_path is not None:
|
|
71
|
+
try:
|
|
72
|
+
checksum = hashlib.sha256(Path(migration.source_path).read_bytes()).hexdigest()
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise ValueError("A migration file is missing or unreadable") from exc
|
|
75
|
+
if checksum != migration.checksum:
|
|
76
|
+
raise ValueError("A migration file changed after discovery")
|
|
77
|
+
if self.dialect == "sqlite":
|
|
78
|
+
found = tx.fetch_one("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
79
|
+
("muscles_data_migrations",))
|
|
80
|
+
else:
|
|
81
|
+
found = tx.fetch_one("SELECT to_regclass(%s) AS name", ("muscles_data_migrations",))
|
|
82
|
+
if not found or not found["name"]:
|
|
83
|
+
return []
|
|
84
|
+
rows = tx.fetch_all(f"SELECT revision, checksum FROM muscles_data_migrations WHERE source = {self._param} ORDER BY revision",
|
|
85
|
+
(self.source,))
|
|
86
|
+
expected = self.migrations[:len(rows)]
|
|
87
|
+
if len(rows) > len(self.migrations) or [row["revision"] for row in rows] != [item.revision for item in expected]:
|
|
88
|
+
raise ValueError("Migration history is missing, unknown or out of order")
|
|
89
|
+
if any(row["checksum"] != item.checksum for row, item in zip(rows, expected)):
|
|
90
|
+
raise ValueError("An applied migration has changed")
|
|
91
|
+
return [row["revision"] for row in rows]
|
|
92
|
+
|
|
93
|
+
def status(self) -> dict:
|
|
94
|
+
with self.db.transaction() as tx:
|
|
95
|
+
current = self._state(tx)
|
|
96
|
+
return {"current": current[-1:] if current else [], "applied": current,
|
|
97
|
+
"pending": [item.revision for item in self.migrations[len(current):]],
|
|
98
|
+
"verification": "checksums"}
|
|
99
|
+
|
|
100
|
+
def plan(self) -> list[str]:
|
|
101
|
+
return self.status()["pending"]
|
|
102
|
+
|
|
103
|
+
def upgrade(self, target: str = "head") -> None:
|
|
104
|
+
revisions = [item.revision for item in self.migrations]
|
|
105
|
+
if target != "head" and target not in revisions:
|
|
106
|
+
raise ValueError("Unknown migration target")
|
|
107
|
+
end = len(revisions) if target == "head" else revisions.index(target) + 1
|
|
108
|
+
with self._write_transaction() as tx:
|
|
109
|
+
current = self._state(tx)
|
|
110
|
+
if end < len(current):
|
|
111
|
+
raise ValueError("Upgrade target is older than current revision")
|
|
112
|
+
if end == len(current):
|
|
113
|
+
return
|
|
114
|
+
tx.execute("CREATE TABLE IF NOT EXISTS muscles_data_migrations ("
|
|
115
|
+
"source TEXT NOT NULL, revision TEXT NOT NULL, checksum TEXT NOT NULL, "
|
|
116
|
+
"applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (source, revision))")
|
|
117
|
+
for item in self.migrations[len(current):end]:
|
|
118
|
+
item.upgrade(tx)
|
|
119
|
+
tx.execute(f"INSERT INTO muscles_data_migrations (source, revision, checksum) VALUES ({self._param}, {self._param}, {self._param})",
|
|
120
|
+
(self.source, item.revision, item.checksum))
|
|
121
|
+
|
|
122
|
+
def downgrade(self, target: str) -> None:
|
|
123
|
+
revisions = [item.revision for item in self.migrations]
|
|
124
|
+
if target != "base" and target not in revisions:
|
|
125
|
+
raise ValueError("Unknown migration target")
|
|
126
|
+
end = 0 if target == "base" else revisions.index(target) + 1
|
|
127
|
+
with self._write_transaction() as tx:
|
|
128
|
+
current = self._state(tx)
|
|
129
|
+
if end > len(current):
|
|
130
|
+
raise ValueError("Downgrade target is not applied")
|
|
131
|
+
to_revert = tuple(reversed(self.migrations[end:len(current)]))
|
|
132
|
+
if any(item.downgrade is None for item in to_revert):
|
|
133
|
+
raise ValueError("A migration has no downgrade")
|
|
134
|
+
for item in to_revert:
|
|
135
|
+
item.downgrade(tx)
|
|
136
|
+
tx.execute(f"DELETE FROM muscles_data_migrations WHERE source = {self._param} AND revision = {self._param}",
|
|
137
|
+
(self.source, item.revision))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class NativeSqlMigrationSourceBackend:
|
|
141
|
+
"""MigrationRegistry backend that owns a short-lived native SQL connection per command."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
url: str,
|
|
146
|
+
source: str,
|
|
147
|
+
migrations: list[NativeSqlMigration],
|
|
148
|
+
) -> None:
|
|
149
|
+
if not isinstance(url, str) or not url.strip():
|
|
150
|
+
raise ValueError("A database URL is required for native SQL migrations")
|
|
151
|
+
self.url = url
|
|
152
|
+
self.source = source
|
|
153
|
+
self.migrations = list(migrations)
|
|
154
|
+
|
|
155
|
+
def _open_database(self) -> tuple[SqlDatabase, str]:
|
|
156
|
+
if self.url.startswith("sqlite:///"):
|
|
157
|
+
path = unquote(self.url[len("sqlite:///"):])
|
|
158
|
+
if not path:
|
|
159
|
+
raise ValueError("SQLite migration URL must include a database path")
|
|
160
|
+
return SqlDatabase.sqlite(path), "sqlite"
|
|
161
|
+
if self.url.startswith(("postgresql://", "postgresql+psycopg://", "postgresql+psycopg2://")):
|
|
162
|
+
return SqlDatabase.postgres(self.url), "postgres"
|
|
163
|
+
raise ValueError("Native SQL migrations require a PostgreSQL or SQLite URL")
|
|
164
|
+
|
|
165
|
+
def _backend(self, database: SqlDatabase, dialect: str) -> NativeSqlMigrationBackend:
|
|
166
|
+
return NativeSqlMigrationBackend(database, self.source, self.migrations, dialect=dialect)
|
|
167
|
+
|
|
168
|
+
def _run(self, operation: str, *args):
|
|
169
|
+
database, dialect = self._open_database()
|
|
170
|
+
try:
|
|
171
|
+
backend = self._backend(database, dialect)
|
|
172
|
+
return getattr(backend, operation)(*args)
|
|
173
|
+
finally:
|
|
174
|
+
database.close()
|
|
175
|
+
|
|
176
|
+
def upgrade(self, target: str = "head") -> None:
|
|
177
|
+
self._run("upgrade", target)
|
|
178
|
+
|
|
179
|
+
def downgrade(self, target: str) -> None:
|
|
180
|
+
self._run("downgrade", target)
|
|
181
|
+
|
|
182
|
+
def plan(self) -> list[str]:
|
|
183
|
+
return self.status()["pending"]
|
|
184
|
+
|
|
185
|
+
def status(self) -> dict:
|
|
186
|
+
database, dialect = self._open_database()
|
|
187
|
+
try:
|
|
188
|
+
backend = self._backend(database, dialect)
|
|
189
|
+
with database.transaction() as transaction:
|
|
190
|
+
applied = backend._state(transaction)
|
|
191
|
+
revisions = [item.revision for item in self.migrations]
|
|
192
|
+
return {
|
|
193
|
+
"current": applied[-1:] if applied else [],
|
|
194
|
+
"heads": revisions[-1:] if revisions else [],
|
|
195
|
+
"applied": applied,
|
|
196
|
+
"pending": revisions[len(applied):],
|
|
197
|
+
"verification": "checksums",
|
|
198
|
+
}
|
|
199
|
+
finally:
|
|
200
|
+
database.close()
|
|
@@ -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-data-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)]
|
|
@@ -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
|
+
|