databasesupasafe 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.
- databasesupasafe-1.0.0.dist-info/METADATA +251 -0
- databasesupasafe-1.0.0.dist-info/RECORD +9 -0
- databasesupasafe-1.0.0.dist-info/WHEEL +5 -0
- databasesupasafe-1.0.0.dist-info/top_level.txt +1 -0
- dbhandler/__init__.py +26 -0
- dbhandler/core.py +486 -0
- dbhandler/exceptions.py +23 -0
- dbhandler/models.py +285 -0
- dbhandler/query.py +216 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: databasesupasafe
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lightweight Python package for managing SQLite, PostgreSQL, and MySQL databases.
|
|
5
|
+
Home-page: https://github.com/yourname/dbhandler
|
|
6
|
+
Author: dbhandler contributors
|
|
7
|
+
Author-email: you@example.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: api-feature
|
|
23
|
+
Provides-Extra: postgresql
|
|
24
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "postgresql"
|
|
25
|
+
Provides-Extra: mysql
|
|
26
|
+
Requires-Dist: mysql-connector-python>=8.0; extra == "mysql"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "all"
|
|
29
|
+
Requires-Dist: mysql-connector-python>=8.0; extra == "all"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
33
|
+
Requires-Dist: black; extra == "dev"
|
|
34
|
+
Requires-Dist: isort; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy; extra == "dev"
|
|
36
|
+
Dynamic: author
|
|
37
|
+
Dynamic: author-email
|
|
38
|
+
Dynamic: classifier
|
|
39
|
+
Dynamic: description
|
|
40
|
+
Dynamic: description-content-type
|
|
41
|
+
Dynamic: home-page
|
|
42
|
+
Dynamic: license
|
|
43
|
+
Dynamic: provides-extra
|
|
44
|
+
Dynamic: requires-dist
|
|
45
|
+
Dynamic: requires-python
|
|
46
|
+
Dynamic: summary
|
|
47
|
+
|
|
48
|
+
# dbhandler
|
|
49
|
+
|
|
50
|
+
A lightweight Python package for managing **SQLite**, **PostgreSQL**, and **MySQL** databases — with a fluent query builder and an ORM-style model layer.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# SQLite only (no extra dependencies)
|
|
58
|
+
pip install .
|
|
59
|
+
|
|
60
|
+
# With PostgreSQL support
|
|
61
|
+
pip install ".[postgresql]"
|
|
62
|
+
|
|
63
|
+
# With MySQL support
|
|
64
|
+
pip install ".[mysql]"
|
|
65
|
+
|
|
66
|
+
# Everything
|
|
67
|
+
pip install ".[all]"
|
|
68
|
+
|
|
69
|
+
# Development tools
|
|
70
|
+
pip install ".[dev]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
### DBHandler — direct SQL
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from dbhandler import DBHandler
|
|
81
|
+
|
|
82
|
+
# SQLite (in-memory)
|
|
83
|
+
with DBHandler("sqlite", database=":memory:") as db:
|
|
84
|
+
db.execute("""
|
|
85
|
+
CREATE TABLE users (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
name TEXT NOT NULL,
|
|
88
|
+
email TEXT NOT NULL UNIQUE,
|
|
89
|
+
age INTEGER DEFAULT 0
|
|
90
|
+
)
|
|
91
|
+
""")
|
|
92
|
+
|
|
93
|
+
# Insert a single row
|
|
94
|
+
db.insert("users", {"name": "Alice", "email": "alice@example.com", "age": 30})
|
|
95
|
+
|
|
96
|
+
# Insert many rows
|
|
97
|
+
db.insert_many("users", [
|
|
98
|
+
{"name": "Bob", "email": "bob@example.com", "age": 25},
|
|
99
|
+
{"name": "Carol", "email": "carol@example.com", "age": 35},
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
# Select with a WHERE clause
|
|
103
|
+
adults = db.select("users", where="age >= ?", params=(30,), order_by="name ASC")
|
|
104
|
+
for user in adults:
|
|
105
|
+
print(user["name"], user["age"])
|
|
106
|
+
|
|
107
|
+
# Update
|
|
108
|
+
db.update("users", {"age": 31}, where="name = ?", where_params=("Alice",))
|
|
109
|
+
|
|
110
|
+
# Delete
|
|
111
|
+
db.delete("users", where="name = ?", params=("Bob",))
|
|
112
|
+
|
|
113
|
+
# Raw fetch
|
|
114
|
+
row = db.fetchone("SELECT * FROM users WHERE email = ?", ("alice@example.com",))
|
|
115
|
+
print(row)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### PostgreSQL / MySQL
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
db = DBHandler(
|
|
122
|
+
"postgresql",
|
|
123
|
+
host="localhost",
|
|
124
|
+
port=5432,
|
|
125
|
+
database="mydb",
|
|
126
|
+
user="admin",
|
|
127
|
+
password="secret",
|
|
128
|
+
)
|
|
129
|
+
db.connect()
|
|
130
|
+
# ... same API as SQLite ...
|
|
131
|
+
db.disconnect()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### QueryBuilder — fluent SQL construction
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from dbhandler import DBHandler, QueryBuilder
|
|
140
|
+
|
|
141
|
+
with DBHandler("sqlite", database="app.db") as db:
|
|
142
|
+
|
|
143
|
+
# SELECT
|
|
144
|
+
sql, params = (
|
|
145
|
+
QueryBuilder("users")
|
|
146
|
+
.select("id", "name", "email")
|
|
147
|
+
.where("age > ?", 18)
|
|
148
|
+
.order_by("name ASC")
|
|
149
|
+
.limit(10)
|
|
150
|
+
.build()
|
|
151
|
+
)
|
|
152
|
+
rows = db.fetchall(sql, params)
|
|
153
|
+
|
|
154
|
+
# INSERT
|
|
155
|
+
sql, params = QueryBuilder("users").insert(name="Dave", email="d@d.com", age=22).build()
|
|
156
|
+
db.execute(sql, params)
|
|
157
|
+
|
|
158
|
+
# UPDATE
|
|
159
|
+
sql, params = (
|
|
160
|
+
QueryBuilder("users")
|
|
161
|
+
.update(email="dave@new.com")
|
|
162
|
+
.where("name = ?", "Dave")
|
|
163
|
+
.build()
|
|
164
|
+
)
|
|
165
|
+
db.execute(sql, params)
|
|
166
|
+
|
|
167
|
+
# DELETE
|
|
168
|
+
sql, params = QueryBuilder("users").delete().where("id = ?", 99).build()
|
|
169
|
+
db.execute(sql, params)
|
|
170
|
+
|
|
171
|
+
db.commit()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
### BaseModel — ORM-style interface
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from dbhandler import DBHandler, BaseModel
|
|
180
|
+
from dbhandler.models import Field
|
|
181
|
+
|
|
182
|
+
class User(BaseModel):
|
|
183
|
+
__table__ = "users"
|
|
184
|
+
|
|
185
|
+
id = Field(int, primary_key=True)
|
|
186
|
+
name = Field(str, nullable=False)
|
|
187
|
+
email = Field(str, nullable=False, unique=True)
|
|
188
|
+
age = Field(int, default=0)
|
|
189
|
+
|
|
190
|
+
db = DBHandler("sqlite", database="app.db")
|
|
191
|
+
db.connect()
|
|
192
|
+
|
|
193
|
+
User.__db__ = db
|
|
194
|
+
User.create_table()
|
|
195
|
+
|
|
196
|
+
# Create
|
|
197
|
+
alice = User(name="Alice", email="alice@example.com", age=30)
|
|
198
|
+
alice.save()
|
|
199
|
+
|
|
200
|
+
# Read
|
|
201
|
+
all_users = User.all()
|
|
202
|
+
alice = User.get(id=1)
|
|
203
|
+
adults = User.filter("age >= ?", (18,))
|
|
204
|
+
total = User.count()
|
|
205
|
+
|
|
206
|
+
# Update
|
|
207
|
+
alice.age = 31
|
|
208
|
+
alice.save()
|
|
209
|
+
|
|
210
|
+
# Delete
|
|
211
|
+
alice.delete()
|
|
212
|
+
|
|
213
|
+
db.disconnect()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
### Transactions
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
with DBHandler("sqlite", database="app.db") as db:
|
|
222
|
+
with db.transaction():
|
|
223
|
+
db.insert("orders", {"user_id": 1, "total": 99.99})
|
|
224
|
+
db.insert("order_items", {"order_id": 1, "product_id": 42, "qty": 2})
|
|
225
|
+
# committed automatically; rolled back on any exception
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Schema Utilities
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
db.table_exists("users") # → True / False
|
|
234
|
+
db.get_tables() # → ["users", "orders", ...]
|
|
235
|
+
db.get_columns("users") # → [{"name": "id", "type": "INTEGER", ...}, ...]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Running Tests
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
pip install ".[dev]"
|
|
244
|
+
pytest tests/ -v --cov=dbhandler
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dbhandler/__init__.py,sha256=YOIXNiCwtoyfsDXLZBtiWgc7CRIavyrooZdKyHbJ5Pg,513
|
|
2
|
+
dbhandler/core.py,sha256=OwcdXOnem2UInycy8oIu-9cH810ddPYmXKNj9rPLQIk,16816
|
|
3
|
+
dbhandler/exceptions.py,sha256=Q10Lg9V6srkd4HxXYoNG2VwsLiXQD9MRtK48u6U65u0,483
|
|
4
|
+
dbhandler/models.py,sha256=taMHobJpgPJhACV5bTYFaRd-ahA0NPoys7hgspjb_O8,9024
|
|
5
|
+
dbhandler/query.py,sha256=gNOWVX10C3nOnVaRNX5YXkVHiUjNdunfbyxP1CHJD00,7359
|
|
6
|
+
databasesupasafe-1.0.0.dist-info/METADATA,sha256=8NGC_XIHGxhVfDJfHYmJNOqi9-_vg9B7B5ttVidveuQ,6026
|
|
7
|
+
databasesupasafe-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
databasesupasafe-1.0.0.dist-info/top_level.txt,sha256=PH3fZCYCR6JCo3kFrFaT6uKg7Ov7DXxYjeIYAz2vdlk,10
|
|
9
|
+
databasesupasafe-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dbhandler
|
dbhandler/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbhandler - A Python package for handling databases with ease.
|
|
3
|
+
Supports SQLite, PostgreSQL, and MySQL.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .core import DBHandler
|
|
7
|
+
from .query import QueryBuilder
|
|
8
|
+
from .models import BaseModel
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
DBHandlerError,
|
|
11
|
+
ConnectionError,
|
|
12
|
+
QueryError,
|
|
13
|
+
ModelError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
17
|
+
__author__ = "dbhandler contributors"
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DBHandler",
|
|
20
|
+
"QueryBuilder",
|
|
21
|
+
"BaseModel",
|
|
22
|
+
"DBHandlerError",
|
|
23
|
+
"ConnectionError",
|
|
24
|
+
"QueryError",
|
|
25
|
+
"ModelError",
|
|
26
|
+
]
|
dbhandler/core.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbhandler.core - Core DBHandler class for managing database connections.
|
|
3
|
+
Supports SQLite, PostgreSQL (via psycopg2), and MySQL (via mysql-connector-python).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
import logging
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from .exceptions import ConnectionError, QueryError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DBHandler:
|
|
17
|
+
"""
|
|
18
|
+
A unified interface for interacting with SQLite, PostgreSQL, and MySQL databases.
|
|
19
|
+
|
|
20
|
+
Usage (SQLite):
|
|
21
|
+
db = DBHandler("sqlite", database="mydb.sqlite3")
|
|
22
|
+
db.connect()
|
|
23
|
+
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
24
|
+
db.disconnect()
|
|
25
|
+
|
|
26
|
+
Usage (PostgreSQL):
|
|
27
|
+
db = DBHandler("postgresql", host="localhost", port=5432,
|
|
28
|
+
database="mydb", user="admin", password="secret")
|
|
29
|
+
db.connect()
|
|
30
|
+
|
|
31
|
+
Usage (MySQL):
|
|
32
|
+
db = DBHandler("mysql", host="localhost", port=3306,
|
|
33
|
+
database="mydb", user="admin", password="secret")
|
|
34
|
+
db.connect()
|
|
35
|
+
|
|
36
|
+
Context manager support:
|
|
37
|
+
with DBHandler("sqlite", database=":memory:") as db:
|
|
38
|
+
db.execute("SELECT 1")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
SUPPORTED_BACKENDS = ("sqlite", "postgresql", "mysql")
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
backend: str,
|
|
46
|
+
*,
|
|
47
|
+
database: str = ":memory:",
|
|
48
|
+
host: str = "localhost",
|
|
49
|
+
port: Optional[int] = None,
|
|
50
|
+
user: Optional[str] = None,
|
|
51
|
+
password: Optional[str] = None,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize the DBHandler.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
backend: One of 'sqlite', 'postgresql', or 'mysql'.
|
|
59
|
+
database: Database name / file path.
|
|
60
|
+
host: Hostname (PostgreSQL / MySQL only).
|
|
61
|
+
port: Port number (defaults: PostgreSQL=5432, MySQL=3306).
|
|
62
|
+
user: Username (PostgreSQL / MySQL only).
|
|
63
|
+
password: Password (PostgreSQL / MySQL only).
|
|
64
|
+
**kwargs: Additional driver-specific keyword arguments.
|
|
65
|
+
"""
|
|
66
|
+
backend = backend.lower()
|
|
67
|
+
if backend not in self.SUPPORTED_BACKENDS:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Unsupported backend '{backend}'. "
|
|
70
|
+
f"Choose from: {', '.join(self.SUPPORTED_BACKENDS)}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.backend = backend
|
|
74
|
+
self.database = database
|
|
75
|
+
self.host = host
|
|
76
|
+
self.port = port
|
|
77
|
+
self.user = user
|
|
78
|
+
self.password = password
|
|
79
|
+
self.extra = kwargs
|
|
80
|
+
|
|
81
|
+
self._conn = None
|
|
82
|
+
self._cursor = None
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Connection management
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def connect(self) -> None:
|
|
89
|
+
"""Open the database connection."""
|
|
90
|
+
try:
|
|
91
|
+
if self.backend == "sqlite":
|
|
92
|
+
self._conn = sqlite3.connect(self.database)
|
|
93
|
+
self._conn.row_factory = sqlite3.Row
|
|
94
|
+
|
|
95
|
+
elif self.backend == "postgresql":
|
|
96
|
+
try:
|
|
97
|
+
import psycopg2
|
|
98
|
+
import psycopg2.extras
|
|
99
|
+
except ImportError:
|
|
100
|
+
raise ConnectionError(
|
|
101
|
+
"psycopg2 is required for PostgreSQL support. "
|
|
102
|
+
"Install it with: pip install psycopg2-binary"
|
|
103
|
+
)
|
|
104
|
+
self._conn = psycopg2.connect(
|
|
105
|
+
host=self.host,
|
|
106
|
+
port=self.port or 5432,
|
|
107
|
+
dbname=self.database,
|
|
108
|
+
user=self.user,
|
|
109
|
+
password=self.password,
|
|
110
|
+
**self.extra,
|
|
111
|
+
)
|
|
112
|
+
self._conn.autocommit = False
|
|
113
|
+
|
|
114
|
+
elif self.backend == "mysql":
|
|
115
|
+
try:
|
|
116
|
+
import mysql.connector
|
|
117
|
+
except ImportError:
|
|
118
|
+
raise ConnectionError(
|
|
119
|
+
"mysql-connector-python is required for MySQL support. "
|
|
120
|
+
"Install it with: pip install mysql-connector-python"
|
|
121
|
+
)
|
|
122
|
+
self._conn = mysql.connector.connect(
|
|
123
|
+
host=self.host,
|
|
124
|
+
port=self.port or 3306,
|
|
125
|
+
database=self.database,
|
|
126
|
+
user=self.user,
|
|
127
|
+
password=self.password,
|
|
128
|
+
**self.extra,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self._cursor = self._conn.cursor()
|
|
132
|
+
logger.info("Connected to %s database '%s'.", self.backend, self.database)
|
|
133
|
+
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise ConnectionError(f"Failed to connect: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
def disconnect(self) -> None:
|
|
138
|
+
"""Close the database connection."""
|
|
139
|
+
if self._cursor:
|
|
140
|
+
self._cursor.close()
|
|
141
|
+
self._cursor = None
|
|
142
|
+
if self._conn:
|
|
143
|
+
self._conn.close()
|
|
144
|
+
self._conn = None
|
|
145
|
+
logger.info("Disconnected from %s database '%s'.", self.backend, self.database)
|
|
146
|
+
|
|
147
|
+
def is_connected(self) -> bool:
|
|
148
|
+
"""Return True if the connection is active."""
|
|
149
|
+
return self._conn is not None
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Context manager
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> "DBHandler":
|
|
156
|
+
self.connect()
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
160
|
+
if exc_type is None:
|
|
161
|
+
self.commit()
|
|
162
|
+
else:
|
|
163
|
+
self.rollback()
|
|
164
|
+
self.disconnect()
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Transaction helpers
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def commit(self) -> None:
|
|
171
|
+
"""Commit the current transaction."""
|
|
172
|
+
self._require_connection()
|
|
173
|
+
self._conn.commit()
|
|
174
|
+
|
|
175
|
+
def rollback(self) -> None:
|
|
176
|
+
"""Roll back the current transaction."""
|
|
177
|
+
self._require_connection()
|
|
178
|
+
self._conn.rollback()
|
|
179
|
+
|
|
180
|
+
@contextmanager
|
|
181
|
+
def transaction(self):
|
|
182
|
+
"""
|
|
183
|
+
Context manager for explicit transactions.
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
with db.transaction():
|
|
187
|
+
db.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
|
|
188
|
+
db.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))
|
|
189
|
+
"""
|
|
190
|
+
self._require_connection()
|
|
191
|
+
try:
|
|
192
|
+
yield self
|
|
193
|
+
self.commit()
|
|
194
|
+
except Exception:
|
|
195
|
+
self.rollback()
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Query execution
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def execute(
|
|
203
|
+
self,
|
|
204
|
+
sql: str,
|
|
205
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
206
|
+
) -> Any:
|
|
207
|
+
"""
|
|
208
|
+
Execute a single SQL statement.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
sql: The SQL statement to run.
|
|
212
|
+
params: Optional bind parameters (tuple or dict).
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The cursor object after execution.
|
|
216
|
+
"""
|
|
217
|
+
self._require_connection()
|
|
218
|
+
try:
|
|
219
|
+
if params:
|
|
220
|
+
self._cursor.execute(sql, params)
|
|
221
|
+
else:
|
|
222
|
+
self._cursor.execute(sql)
|
|
223
|
+
logger.debug("Executed: %s | params=%s", sql.strip(), params)
|
|
224
|
+
return self._cursor
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
raise QueryError(f"Query failed: {exc}\nSQL: {sql}") from exc
|
|
227
|
+
|
|
228
|
+
def executemany(
|
|
229
|
+
self,
|
|
230
|
+
sql: str,
|
|
231
|
+
params_seq: List[Union[Tuple, Dict]],
|
|
232
|
+
) -> Any:
|
|
233
|
+
"""
|
|
234
|
+
Execute a statement against a sequence of parameter sets.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
sql: The SQL statement template.
|
|
238
|
+
params_seq: A list of tuples or dicts to bind.
|
|
239
|
+
"""
|
|
240
|
+
self._require_connection()
|
|
241
|
+
try:
|
|
242
|
+
self._cursor.executemany(sql, params_seq)
|
|
243
|
+
logger.debug("Executemany: %s | %d rows", sql.strip(), len(params_seq))
|
|
244
|
+
return self._cursor
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
raise QueryError(f"Executemany failed: {exc}\nSQL: {sql}") from exc
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# Fetch helpers
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def fetchone(
|
|
253
|
+
self,
|
|
254
|
+
sql: str,
|
|
255
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
256
|
+
) -> Optional[Dict[str, Any]]:
|
|
257
|
+
"""Execute *sql* and return the first row as a dict, or None."""
|
|
258
|
+
cursor = self.execute(sql, params)
|
|
259
|
+
row = cursor.fetchone()
|
|
260
|
+
return self._row_to_dict(row) if row else None
|
|
261
|
+
|
|
262
|
+
def fetchall(
|
|
263
|
+
self,
|
|
264
|
+
sql: str,
|
|
265
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
266
|
+
) -> List[Dict[str, Any]]:
|
|
267
|
+
"""Execute *sql* and return all rows as a list of dicts."""
|
|
268
|
+
cursor = self.execute(sql, params)
|
|
269
|
+
rows = cursor.fetchall()
|
|
270
|
+
return [self._row_to_dict(r) for r in rows]
|
|
271
|
+
|
|
272
|
+
def fetchmany(
|
|
273
|
+
self,
|
|
274
|
+
sql: str,
|
|
275
|
+
size: int = 100,
|
|
276
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
277
|
+
) -> List[Dict[str, Any]]:
|
|
278
|
+
"""Execute *sql* and return up to *size* rows as a list of dicts."""
|
|
279
|
+
cursor = self.execute(sql, params)
|
|
280
|
+
rows = cursor.fetchmany(size)
|
|
281
|
+
return [self._row_to_dict(r) for r in rows]
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# CRUD shortcuts
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def insert(self, table: str, data: Dict[str, Any]) -> int:
|
|
288
|
+
"""
|
|
289
|
+
Insert a single row and return the last inserted row id.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
table: Target table name.
|
|
293
|
+
data: Column → value mapping.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
The last inserted row ID.
|
|
297
|
+
"""
|
|
298
|
+
cols = ", ".join(data.keys())
|
|
299
|
+
placeholders = self._placeholders(len(data))
|
|
300
|
+
sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
|
|
301
|
+
self.execute(sql, tuple(data.values()))
|
|
302
|
+
return self._cursor.lastrowid
|
|
303
|
+
|
|
304
|
+
def insert_many(self, table: str, rows: List[Dict[str, Any]]) -> None:
|
|
305
|
+
"""Insert multiple rows efficiently using executemany."""
|
|
306
|
+
if not rows:
|
|
307
|
+
return
|
|
308
|
+
cols = ", ".join(rows[0].keys())
|
|
309
|
+
placeholders = self._placeholders(len(rows[0]))
|
|
310
|
+
sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
|
|
311
|
+
self.executemany(sql, [tuple(r.values()) for r in rows])
|
|
312
|
+
|
|
313
|
+
def select(
|
|
314
|
+
self,
|
|
315
|
+
table: str,
|
|
316
|
+
*,
|
|
317
|
+
columns: Union[str, List[str]] = "*",
|
|
318
|
+
where: Optional[str] = None,
|
|
319
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
320
|
+
order_by: Optional[str] = None,
|
|
321
|
+
limit: Optional[int] = None,
|
|
322
|
+
offset: Optional[int] = None,
|
|
323
|
+
) -> List[Dict[str, Any]]:
|
|
324
|
+
"""
|
|
325
|
+
Build and execute a SELECT statement.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
table: Source table name.
|
|
329
|
+
columns: Column(s) to select (default '*').
|
|
330
|
+
where: Optional WHERE clause (e.g. "age > ?").
|
|
331
|
+
params: Bind parameters for the WHERE clause.
|
|
332
|
+
order_by: Optional ORDER BY clause.
|
|
333
|
+
limit: Row limit.
|
|
334
|
+
offset: Row offset.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
A list of row dicts.
|
|
338
|
+
"""
|
|
339
|
+
if isinstance(columns, list):
|
|
340
|
+
columns = ", ".join(columns)
|
|
341
|
+
|
|
342
|
+
sql = f"SELECT {columns} FROM {table}"
|
|
343
|
+
if where:
|
|
344
|
+
sql += f" WHERE {where}"
|
|
345
|
+
if order_by:
|
|
346
|
+
sql += f" ORDER BY {order_by}"
|
|
347
|
+
if limit is not None:
|
|
348
|
+
sql += f" LIMIT {int(limit)}"
|
|
349
|
+
if offset is not None:
|
|
350
|
+
sql += f" OFFSET {int(offset)}"
|
|
351
|
+
|
|
352
|
+
return self.fetchall(sql, params)
|
|
353
|
+
|
|
354
|
+
def update(
|
|
355
|
+
self,
|
|
356
|
+
table: str,
|
|
357
|
+
data: Dict[str, Any],
|
|
358
|
+
where: str,
|
|
359
|
+
where_params: Optional[Union[Tuple, Dict]] = None,
|
|
360
|
+
) -> int:
|
|
361
|
+
"""
|
|
362
|
+
Update rows in *table* and return the number of affected rows.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
table: Target table name.
|
|
366
|
+
data: Column → new-value mapping.
|
|
367
|
+
where: WHERE clause (required to prevent accidental mass updates).
|
|
368
|
+
where_params: Bind parameters for the WHERE clause.
|
|
369
|
+
"""
|
|
370
|
+
set_clause = ", ".join(
|
|
371
|
+
f"{col} = {self._single_placeholder()}" for col in data.keys()
|
|
372
|
+
)
|
|
373
|
+
sql = f"UPDATE {table} SET {set_clause} WHERE {where}"
|
|
374
|
+
params: tuple = tuple(data.values())
|
|
375
|
+
if where_params:
|
|
376
|
+
params += tuple(where_params) if not isinstance(where_params, dict) else ()
|
|
377
|
+
self.execute(sql, params)
|
|
378
|
+
return self._cursor.rowcount
|
|
379
|
+
|
|
380
|
+
def delete(
|
|
381
|
+
self,
|
|
382
|
+
table: str,
|
|
383
|
+
where: str,
|
|
384
|
+
params: Optional[Union[Tuple, Dict]] = None,
|
|
385
|
+
) -> int:
|
|
386
|
+
"""
|
|
387
|
+
Delete rows from *table* and return the number of affected rows.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
table: Target table name.
|
|
391
|
+
where: WHERE clause (required).
|
|
392
|
+
params: Bind parameters.
|
|
393
|
+
"""
|
|
394
|
+
sql = f"DELETE FROM {table} WHERE {where}"
|
|
395
|
+
self.execute(sql, params)
|
|
396
|
+
return self._cursor.rowcount
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------
|
|
399
|
+
# Schema helpers
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def table_exists(self, table: str) -> bool:
|
|
403
|
+
"""Return True if *table* exists in the database."""
|
|
404
|
+
if self.backend == "sqlite":
|
|
405
|
+
row = self.fetchone(
|
|
406
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
407
|
+
(table,),
|
|
408
|
+
)
|
|
409
|
+
elif self.backend == "postgresql":
|
|
410
|
+
row = self.fetchone(
|
|
411
|
+
"SELECT 1 FROM information_schema.tables WHERE table_name=%s",
|
|
412
|
+
(table,),
|
|
413
|
+
)
|
|
414
|
+
else: # mysql
|
|
415
|
+
row = self.fetchone(
|
|
416
|
+
"SELECT 1 FROM information_schema.tables WHERE table_name=%s AND table_schema=DATABASE()",
|
|
417
|
+
(table,),
|
|
418
|
+
)
|
|
419
|
+
return row is not None
|
|
420
|
+
|
|
421
|
+
def get_tables(self) -> List[str]:
|
|
422
|
+
"""Return a list of all table names in the database."""
|
|
423
|
+
if self.backend == "sqlite":
|
|
424
|
+
rows = self.fetchall(
|
|
425
|
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
426
|
+
)
|
|
427
|
+
return [r["name"] for r in rows]
|
|
428
|
+
elif self.backend == "postgresql":
|
|
429
|
+
rows = self.fetchall(
|
|
430
|
+
"SELECT table_name FROM information_schema.tables "
|
|
431
|
+
"WHERE table_schema='public' ORDER BY table_name"
|
|
432
|
+
)
|
|
433
|
+
return [r["table_name"] for r in rows]
|
|
434
|
+
else: # mysql
|
|
435
|
+
rows = self.fetchall("SHOW TABLES")
|
|
436
|
+
return [list(r.values())[0] for r in rows]
|
|
437
|
+
|
|
438
|
+
def get_columns(self, table: str) -> List[Dict[str, Any]]:
|
|
439
|
+
"""Return column info for the given table."""
|
|
440
|
+
if self.backend == "sqlite":
|
|
441
|
+
return self.fetchall(f"PRAGMA table_info({table})")
|
|
442
|
+
elif self.backend == "postgresql":
|
|
443
|
+
return self.fetchall(
|
|
444
|
+
"SELECT column_name, data_type, is_nullable "
|
|
445
|
+
"FROM information_schema.columns WHERE table_name=%s",
|
|
446
|
+
(table,),
|
|
447
|
+
)
|
|
448
|
+
else: # mysql
|
|
449
|
+
return self.fetchall(
|
|
450
|
+
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE "
|
|
451
|
+
"FROM information_schema.columns WHERE table_name=%s AND table_schema=DATABASE()",
|
|
452
|
+
(table,),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
# Internals
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def _require_connection(self) -> None:
|
|
460
|
+
if self._conn is None:
|
|
461
|
+
raise ConnectionError("Not connected. Call connect() first.")
|
|
462
|
+
|
|
463
|
+
def _placeholders(self, n: int) -> str:
|
|
464
|
+
"""Return comma-separated placeholders for n values."""
|
|
465
|
+
ph = self._single_placeholder()
|
|
466
|
+
return ", ".join([ph] * n)
|
|
467
|
+
|
|
468
|
+
def _single_placeholder(self) -> str:
|
|
469
|
+
return "?" if self.backend == "sqlite" else "%s"
|
|
470
|
+
|
|
471
|
+
def _row_to_dict(self, row: Any) -> Dict[str, Any]:
|
|
472
|
+
"""Convert a database row to a plain dict."""
|
|
473
|
+
if row is None:
|
|
474
|
+
return {}
|
|
475
|
+
if isinstance(row, sqlite3.Row):
|
|
476
|
+
return dict(row)
|
|
477
|
+
if hasattr(row, "_asdict"): # psycopg2 RealDictRow / namedtuple
|
|
478
|
+
return dict(row._asdict())
|
|
479
|
+
if hasattr(self._cursor, "description") and self._cursor.description:
|
|
480
|
+
keys = [d[0] for d in self._cursor.description]
|
|
481
|
+
return dict(zip(keys, row))
|
|
482
|
+
return dict(row)
|
|
483
|
+
|
|
484
|
+
def __repr__(self) -> str:
|
|
485
|
+
status = "connected" if self.is_connected() else "disconnected"
|
|
486
|
+
return f"<DBHandler backend={self.backend!r} database={self.database!r} [{status}]>"
|
dbhandler/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbhandler.exceptions - Custom exception classes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DBHandlerError(Exception):
|
|
7
|
+
"""Base exception for all dbhandler errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConnectionError(DBHandlerError):
|
|
11
|
+
"""Raised when a database connection fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueryError(DBHandlerError):
|
|
15
|
+
"""Raised when a query fails to execute."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelError(DBHandlerError):
|
|
19
|
+
"""Raised when a model operation fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MigrationError(DBHandlerError):
|
|
23
|
+
"""Raised when a migration fails."""
|
dbhandler/models.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbhandler.models - Lightweight ORM-style BaseModel.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .exceptions import ModelError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .core import DBHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelMeta(type):
|
|
16
|
+
"""Metaclass that collects declared fields."""
|
|
17
|
+
|
|
18
|
+
def __new__(mcs, name, bases, namespace):
|
|
19
|
+
fields = {}
|
|
20
|
+
for key, val in list(namespace.items()):
|
|
21
|
+
if isinstance(val, Field):
|
|
22
|
+
fields[key] = val
|
|
23
|
+
namespace["_fields"] = fields
|
|
24
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
25
|
+
return cls
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Field:
|
|
29
|
+
"""
|
|
30
|
+
Descriptor representing a database column.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
class User(BaseModel):
|
|
34
|
+
__table__ = "users"
|
|
35
|
+
name = Field(str, nullable=False)
|
|
36
|
+
email = Field(str, nullable=False, unique=True)
|
|
37
|
+
age = Field(int, default=0)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_TYPE_MAP = {
|
|
41
|
+
int: "INTEGER",
|
|
42
|
+
float: "REAL",
|
|
43
|
+
str: "TEXT",
|
|
44
|
+
bool: "INTEGER",
|
|
45
|
+
bytes: "BLOB",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
type_: type = str,
|
|
51
|
+
*,
|
|
52
|
+
nullable: bool = True,
|
|
53
|
+
default: Any = None,
|
|
54
|
+
unique: bool = False,
|
|
55
|
+
primary_key: bool = False,
|
|
56
|
+
):
|
|
57
|
+
self.type_ = type_
|
|
58
|
+
self.nullable = nullable
|
|
59
|
+
self.default = default
|
|
60
|
+
self.unique = unique
|
|
61
|
+
self.primary_key = primary_key
|
|
62
|
+
self.name: Optional[str] = None # set by BaseModel.__init_subclass__
|
|
63
|
+
|
|
64
|
+
def sql_type(self) -> str:
|
|
65
|
+
return self._TYPE_MAP.get(self.type_, "TEXT")
|
|
66
|
+
|
|
67
|
+
def column_def(self) -> str:
|
|
68
|
+
parts = [self.name, self.sql_type()]
|
|
69
|
+
if self.primary_key:
|
|
70
|
+
parts.append("PRIMARY KEY AUTOINCREMENT")
|
|
71
|
+
else:
|
|
72
|
+
if not self.nullable:
|
|
73
|
+
parts.append("NOT NULL")
|
|
74
|
+
if self.unique:
|
|
75
|
+
parts.append("UNIQUE")
|
|
76
|
+
if self.default is not None:
|
|
77
|
+
parts.append(f"DEFAULT {self.default!r}")
|
|
78
|
+
return " ".join(parts)
|
|
79
|
+
|
|
80
|
+
def __set_name__(self, owner, name):
|
|
81
|
+
self.name = name
|
|
82
|
+
|
|
83
|
+
def __get__(self, obj, objtype=None):
|
|
84
|
+
if obj is None:
|
|
85
|
+
return self
|
|
86
|
+
return obj.__dict__.get(self.name, self.default)
|
|
87
|
+
|
|
88
|
+
def __set__(self, obj, value):
|
|
89
|
+
obj.__dict__[self.name] = value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BaseModel(metaclass=ModelMeta):
|
|
93
|
+
"""
|
|
94
|
+
Lightweight ORM-style base class.
|
|
95
|
+
|
|
96
|
+
Subclass this to define a table model:
|
|
97
|
+
|
|
98
|
+
class User(BaseModel):
|
|
99
|
+
__table__ = "users"
|
|
100
|
+
__db__: DBHandler = None # set at runtime
|
|
101
|
+
|
|
102
|
+
id = Field(int, primary_key=True)
|
|
103
|
+
name = Field(str, nullable=False)
|
|
104
|
+
email = Field(str, nullable=False, unique=True)
|
|
105
|
+
age = Field(int, default=0)
|
|
106
|
+
|
|
107
|
+
# Bind database connection
|
|
108
|
+
User.__db__ = db
|
|
109
|
+
|
|
110
|
+
# Create the table
|
|
111
|
+
User.create_table()
|
|
112
|
+
|
|
113
|
+
# Insert
|
|
114
|
+
user = User(name="Alice", email="alice@example.com", age=30)
|
|
115
|
+
user.save()
|
|
116
|
+
|
|
117
|
+
# Query
|
|
118
|
+
users = User.all()
|
|
119
|
+
alice = User.get(id=1)
|
|
120
|
+
|
|
121
|
+
# Update
|
|
122
|
+
alice.age = 31
|
|
123
|
+
alice.save()
|
|
124
|
+
|
|
125
|
+
# Delete
|
|
126
|
+
alice.delete()
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
__table__: ClassVar[str] = ""
|
|
130
|
+
__db__: ClassVar[Optional["DBHandler"]] = None
|
|
131
|
+
_fields: ClassVar[Dict[str, Field]] = {}
|
|
132
|
+
|
|
133
|
+
def __init__(self, **kwargs: Any):
|
|
134
|
+
self._pk_value: Optional[Any] = None
|
|
135
|
+
for name, field in self.__class__._fields.items():
|
|
136
|
+
value = kwargs.pop(name, field.default)
|
|
137
|
+
setattr(self, name, value)
|
|
138
|
+
if kwargs:
|
|
139
|
+
raise ModelError(f"Unknown fields: {', '.join(kwargs)}")
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Class-level helpers
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def _db(cls) -> "DBHandler":
|
|
147
|
+
if cls.__db__ is None:
|
|
148
|
+
raise ModelError(
|
|
149
|
+
f"No database bound to {cls.__name__}. Set {cls.__name__}.__db__ = db."
|
|
150
|
+
)
|
|
151
|
+
return cls.__db__
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def _pk_field(cls) -> Optional[str]:
|
|
155
|
+
for name, field in cls._fields.items():
|
|
156
|
+
if field.primary_key:
|
|
157
|
+
return name
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def _non_pk_fields(cls) -> Dict[str, Field]:
|
|
162
|
+
return {n: f for n, f in cls._fields.items() if not f.primary_key}
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Schema
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def create_table(cls, if_not_exists: bool = True) -> None:
|
|
170
|
+
"""Create the table in the database based on declared fields."""
|
|
171
|
+
modifier = "IF NOT EXISTS " if if_not_exists else ""
|
|
172
|
+
col_defs = [f.column_def() for f in cls._fields.values()]
|
|
173
|
+
sql = f"CREATE TABLE {modifier}{cls.__table__} ({', '.join(col_defs)})"
|
|
174
|
+
cls._db().execute(sql)
|
|
175
|
+
cls._db().commit()
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def drop_table(cls, if_exists: bool = True) -> None:
|
|
179
|
+
"""Drop the table from the database."""
|
|
180
|
+
modifier = "IF EXISTS " if if_exists else ""
|
|
181
|
+
cls._db().execute(f"DROP TABLE {modifier}{cls.__table__}")
|
|
182
|
+
cls._db().commit()
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# CRUD
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def save(self) -> None:
|
|
189
|
+
"""Insert or update this instance in the database."""
|
|
190
|
+
pk = self.__class__._pk_field()
|
|
191
|
+
data = {
|
|
192
|
+
name: getattr(self, name)
|
|
193
|
+
for name, field in self.__class__._non_pk_fields().items()
|
|
194
|
+
}
|
|
195
|
+
db = self.__class__._db()
|
|
196
|
+
|
|
197
|
+
if pk and getattr(self, pk) is not None:
|
|
198
|
+
# UPDATE
|
|
199
|
+
pk_val = getattr(self, pk)
|
|
200
|
+
ph = db._single_placeholder()
|
|
201
|
+
db.update(
|
|
202
|
+
self.__class__.__table__,
|
|
203
|
+
data,
|
|
204
|
+
where=f"{pk} = {ph}",
|
|
205
|
+
where_params=(pk_val,),
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
# INSERT
|
|
209
|
+
row_id = db.insert(self.__class__.__table__, data)
|
|
210
|
+
if pk:
|
|
211
|
+
setattr(self, pk, row_id)
|
|
212
|
+
db.commit()
|
|
213
|
+
|
|
214
|
+
def delete(self) -> None:
|
|
215
|
+
"""Delete this instance from the database."""
|
|
216
|
+
pk = self.__class__._pk_field()
|
|
217
|
+
if pk is None:
|
|
218
|
+
raise ModelError("Cannot delete: no primary key defined.")
|
|
219
|
+
pk_val = getattr(self, pk)
|
|
220
|
+
if pk_val is None:
|
|
221
|
+
raise ModelError("Cannot delete: primary key value is None.")
|
|
222
|
+
db = self.__class__._db()
|
|
223
|
+
ph = db._single_placeholder()
|
|
224
|
+
db.delete(self.__class__.__table__, where=f"{pk} = {ph}", params=(pk_val,))
|
|
225
|
+
db.commit()
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def get(cls, **kwargs: Any) -> Optional["BaseModel"]:
|
|
229
|
+
"""Return the first matching row as a model instance, or None."""
|
|
230
|
+
db = cls._db()
|
|
231
|
+
ph = db._single_placeholder()
|
|
232
|
+
where = " AND ".join(f"{k} = {ph}" for k in kwargs)
|
|
233
|
+
params = tuple(kwargs.values())
|
|
234
|
+
row = db.fetchone(f"SELECT * FROM {cls.__table__} WHERE {where}", params)
|
|
235
|
+
return cls._from_row(row) if row else None
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def all(cls) -> List["BaseModel"]:
|
|
239
|
+
"""Return all rows as a list of model instances."""
|
|
240
|
+
rows = cls._db().fetchall(f"SELECT * FROM {cls.__table__}")
|
|
241
|
+
return [cls._from_row(r) for r in rows]
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def filter(cls, where: str, params: tuple = ()) -> List["BaseModel"]:
|
|
245
|
+
"""
|
|
246
|
+
Return filtered rows as model instances.
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
adults = User.filter("age >= ?", (18,))
|
|
250
|
+
"""
|
|
251
|
+
rows = cls._db().fetchall(
|
|
252
|
+
f"SELECT * FROM {cls.__table__} WHERE {where}", params
|
|
253
|
+
)
|
|
254
|
+
return [cls._from_row(r) for r in rows]
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def count(cls, where: Optional[str] = None, params: tuple = ()) -> int:
|
|
258
|
+
"""Return the number of rows matching an optional WHERE clause."""
|
|
259
|
+
sql = f"SELECT COUNT(*) AS n FROM {cls.__table__}"
|
|
260
|
+
if where:
|
|
261
|
+
sql += f" WHERE {where}"
|
|
262
|
+
row = cls._db().fetchone(sql, params or None)
|
|
263
|
+
return int(row["n"]) if row else 0
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Internals
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def _from_row(cls, row: Dict[str, Any]) -> "BaseModel":
|
|
271
|
+
instance = cls.__new__(cls)
|
|
272
|
+
instance._pk_value = None
|
|
273
|
+
for name, field in cls._fields.items():
|
|
274
|
+
setattr(instance, name, row.get(name, field.default))
|
|
275
|
+
return instance
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
278
|
+
"""Return the model instance as a plain dictionary."""
|
|
279
|
+
return {name: getattr(self, name) for name in self.__class__._fields}
|
|
280
|
+
|
|
281
|
+
def __repr__(self) -> str:
|
|
282
|
+
fields = ", ".join(
|
|
283
|
+
f"{k}={v!r}" for k, v in self.to_dict().items()
|
|
284
|
+
)
|
|
285
|
+
return f"<{self.__class__.__name__} {fields}>"
|
dbhandler/query.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbhandler.query - Fluent QueryBuilder for constructing SQL statements.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueryBuilder:
|
|
9
|
+
"""
|
|
10
|
+
Fluent SQL query builder.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
qb = QueryBuilder("users")
|
|
14
|
+
sql, params = (
|
|
15
|
+
qb.select("id", "name", "email")
|
|
16
|
+
.where("age > ?", 18)
|
|
17
|
+
.where("active = ?", 1)
|
|
18
|
+
.order_by("name ASC")
|
|
19
|
+
.limit(10)
|
|
20
|
+
.offset(0)
|
|
21
|
+
.build()
|
|
22
|
+
)
|
|
23
|
+
rows = db.fetchall(sql, params)
|
|
24
|
+
|
|
25
|
+
INSERT example:
|
|
26
|
+
sql, params = QueryBuilder("users").insert(name="Alice", age=30).build()
|
|
27
|
+
db.execute(sql, params)
|
|
28
|
+
|
|
29
|
+
UPDATE example:
|
|
30
|
+
sql, params = (
|
|
31
|
+
QueryBuilder("users")
|
|
32
|
+
.update(name="Alice Smith")
|
|
33
|
+
.where("id = ?", 1)
|
|
34
|
+
.build()
|
|
35
|
+
)
|
|
36
|
+
db.execute(sql, params)
|
|
37
|
+
|
|
38
|
+
DELETE example:
|
|
39
|
+
sql, params = QueryBuilder("users").delete().where("id = ?", 5).build()
|
|
40
|
+
db.execute(sql, params)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, table: str):
|
|
44
|
+
self._table = table
|
|
45
|
+
self._operation: Optional[str] = None # SELECT | INSERT | UPDATE | DELETE
|
|
46
|
+
self._columns: List[str] = []
|
|
47
|
+
self._where_clauses: List[str] = []
|
|
48
|
+
self._where_params: List[Any] = []
|
|
49
|
+
self._order: Optional[str] = None
|
|
50
|
+
self._limit_val: Optional[int] = None
|
|
51
|
+
self._offset_val: Optional[int] = None
|
|
52
|
+
self._data: Dict[str, Any] = {}
|
|
53
|
+
self._join_clauses: List[str] = []
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Operation starters
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def select(self, *columns: str) -> "QueryBuilder":
|
|
60
|
+
"""Set to SELECT mode. Pass column names or use default '*'."""
|
|
61
|
+
self._operation = "SELECT"
|
|
62
|
+
self._columns = list(columns) if columns else ["*"]
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def insert(self, **data: Any) -> "QueryBuilder":
|
|
66
|
+
"""Set to INSERT mode with the given column=value pairs."""
|
|
67
|
+
self._operation = "INSERT"
|
|
68
|
+
self._data = data
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def update(self, **data: Any) -> "QueryBuilder":
|
|
72
|
+
"""Set to UPDATE mode with the given column=value pairs."""
|
|
73
|
+
self._operation = "UPDATE"
|
|
74
|
+
self._data = data
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def delete(self) -> "QueryBuilder":
|
|
78
|
+
"""Set to DELETE mode."""
|
|
79
|
+
self._operation = "DELETE"
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Clauses
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def where(self, clause: str, *params: Any) -> "QueryBuilder":
|
|
87
|
+
"""
|
|
88
|
+
Add a WHERE condition (ANDed with previous conditions).
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
.where("age > ?", 18)
|
|
92
|
+
.where("name = ?", "Alice")
|
|
93
|
+
"""
|
|
94
|
+
self._where_clauses.append(clause)
|
|
95
|
+
self._where_params.extend(params)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def join(self, join_clause: str) -> "QueryBuilder":
|
|
99
|
+
"""
|
|
100
|
+
Add a raw JOIN clause.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
.join("INNER JOIN orders ON orders.user_id = users.id")
|
|
104
|
+
"""
|
|
105
|
+
self._join_clauses.append(join_clause)
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def order_by(self, clause: str) -> "QueryBuilder":
|
|
109
|
+
"""
|
|
110
|
+
Set the ORDER BY clause.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
.order_by("created_at DESC")
|
|
114
|
+
"""
|
|
115
|
+
self._order = clause
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def limit(self, n: int) -> "QueryBuilder":
|
|
119
|
+
"""Set the LIMIT clause."""
|
|
120
|
+
self._limit_val = int(n)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def offset(self, n: int) -> "QueryBuilder":
|
|
124
|
+
"""Set the OFFSET clause."""
|
|
125
|
+
self._offset_val = int(n)
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Build
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def build(self, placeholder: str = "?") -> Tuple[str, tuple]:
|
|
133
|
+
"""
|
|
134
|
+
Compile the query into a (sql, params) tuple.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
placeholder: The parameter placeholder style ('?' for SQLite,
|
|
138
|
+
'%s' for PostgreSQL/MySQL).
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A (sql_string, params_tuple) pair ready for cursor.execute().
|
|
142
|
+
"""
|
|
143
|
+
if self._operation is None:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"No operation set. Call .select(), .insert(), .update(), or .delete() first."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if self._operation == "SELECT":
|
|
149
|
+
return self._build_select(placeholder)
|
|
150
|
+
if self._operation == "INSERT":
|
|
151
|
+
return self._build_insert(placeholder)
|
|
152
|
+
if self._operation == "UPDATE":
|
|
153
|
+
return self._build_update(placeholder)
|
|
154
|
+
if self._operation == "DELETE":
|
|
155
|
+
return self._build_delete(placeholder)
|
|
156
|
+
|
|
157
|
+
raise ValueError(f"Unknown operation: {self._operation}")
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Internal builders
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _ph(self, placeholder: str, n: int = 1) -> List[str]:
|
|
164
|
+
return [placeholder] * n
|
|
165
|
+
|
|
166
|
+
def _where_sql(self, placeholder: str) -> Tuple[str, list]:
|
|
167
|
+
if not self._where_clauses:
|
|
168
|
+
return "", []
|
|
169
|
+
# Replace '?' with the target placeholder
|
|
170
|
+
clauses = [c.replace("?", placeholder) for c in self._where_clauses]
|
|
171
|
+
return " WHERE " + " AND ".join(clauses), list(self._where_params)
|
|
172
|
+
|
|
173
|
+
def _build_select(self, ph: str) -> Tuple[str, tuple]:
|
|
174
|
+
cols = ", ".join(self._columns)
|
|
175
|
+
sql = f"SELECT {cols} FROM {self._table}"
|
|
176
|
+
for j in self._join_clauses:
|
|
177
|
+
sql += f" {j}"
|
|
178
|
+
where_sql, where_params = self._where_sql(ph)
|
|
179
|
+
sql += where_sql
|
|
180
|
+
if self._order:
|
|
181
|
+
sql += f" ORDER BY {self._order}"
|
|
182
|
+
if self._limit_val is not None:
|
|
183
|
+
sql += f" LIMIT {self._limit_val}"
|
|
184
|
+
if self._offset_val is not None:
|
|
185
|
+
sql += f" OFFSET {self._offset_val}"
|
|
186
|
+
return sql, tuple(where_params)
|
|
187
|
+
|
|
188
|
+
def _build_insert(self, ph: str) -> Tuple[str, tuple]:
|
|
189
|
+
if not self._data:
|
|
190
|
+
raise ValueError("No data provided for INSERT.")
|
|
191
|
+
cols = ", ".join(self._data.keys())
|
|
192
|
+
placeholders = ", ".join([ph] * len(self._data))
|
|
193
|
+
sql = f"INSERT INTO {self._table} ({cols}) VALUES ({placeholders})"
|
|
194
|
+
return sql, tuple(self._data.values())
|
|
195
|
+
|
|
196
|
+
def _build_update(self, ph: str) -> Tuple[str, tuple]:
|
|
197
|
+
if not self._data:
|
|
198
|
+
raise ValueError("No data provided for UPDATE.")
|
|
199
|
+
set_clause = ", ".join(f"{col} = {ph}" for col in self._data.keys())
|
|
200
|
+
sql = f"UPDATE {self._table} SET {set_clause}"
|
|
201
|
+
where_sql, where_params = self._where_sql(ph)
|
|
202
|
+
sql += where_sql
|
|
203
|
+
return sql, tuple(self._data.values()) + tuple(where_params)
|
|
204
|
+
|
|
205
|
+
def _build_delete(self, ph: str) -> Tuple[str, tuple]:
|
|
206
|
+
sql = f"DELETE FROM {self._table}"
|
|
207
|
+
where_sql, where_params = self._where_sql(ph)
|
|
208
|
+
sql += where_sql
|
|
209
|
+
return sql, tuple(where_params)
|
|
210
|
+
|
|
211
|
+
def __repr__(self) -> str:
|
|
212
|
+
try:
|
|
213
|
+
sql, params = self.build()
|
|
214
|
+
return f"<QueryBuilder sql={sql!r} params={params!r}>"
|
|
215
|
+
except ValueError:
|
|
216
|
+
return f"<QueryBuilder table={self._table!r} op={self._operation!r}>"
|