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.
@@ -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
+