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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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}]>"
@@ -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}>"