SQLPyHelper 0.1.6__py3-none-any.whl → 0.1.8__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.
sqlpyhelper/__init__.py CHANGED
@@ -1,9 +1,15 @@
1
1
  # Match the version in setup.py
2
- __version__ = "0.1.6"
2
+ __version__ = "0.1.8"
3
3
 
4
+ from sqlpyhelper.async_helper import ( # noqa: F401
5
+ AsyncConnectionError,
6
+ AsyncQueryError,
7
+ AsyncSQLPyHelper,
8
+ )
4
9
  from sqlpyhelper.db_helper import ( # noqa: F401
5
10
  BackupError,
6
11
  ConnectionError,
7
12
  QueryError,
8
13
  SQLPyHelperError,
9
14
  )
15
+ from sqlpyhelper.migration import MigrationError # noqa: F401
@@ -0,0 +1,599 @@
1
+ """
2
+ sqlpyhelper.async_helper
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Async-native database helper supporting SQLite, PostgreSQL, MySQL,
5
+ SQL Server, and Oracle.
6
+
7
+ Uses async-native drivers:
8
+ - SQLite: aiosqlite
9
+ - PostgreSQL: asyncpg
10
+ - MySQL: aiomysql
11
+ - SQL Server: aioodbc
12
+ - Oracle: python-oracledb (async mode)
13
+
14
+ Example usage::
15
+
16
+ import asyncio
17
+ from sqlpyhelper.async_helper import AsyncSQLPyHelper
18
+
19
+ async def main():
20
+ async with AsyncSQLPyHelper(db_type="sqlite", database="my.db") as db:
21
+ await db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)")
22
+ await db.execute("INSERT INTO users VALUES ($1, $2)", 1, "Alice")
23
+ rows = await db.fetch_all("SELECT * FROM users")
24
+ print(rows)
25
+
26
+ asyncio.run(main())
27
+ """
28
+
29
+ import logging
30
+ import os
31
+ from typing import Any, Optional
32
+
33
+ from dotenv import load_dotenv
34
+
35
+ load_dotenv()
36
+
37
+ logger = logging.getLogger("sqlpyhelper.async")
38
+
39
+
40
+ class AsyncConnectionError(Exception):
41
+ """Raised when an async database connection fails."""
42
+
43
+
44
+ class AsyncQueryError(Exception):
45
+ """Raised when an async query fails."""
46
+
47
+
48
+ class AsyncSQLPyHelper:
49
+ """
50
+ Async-native database helper with a unified API across
51
+ SQLite, PostgreSQL, MySQL, SQL Server, and Oracle.
52
+
53
+ Use as an async context manager::
54
+
55
+ async with AsyncSQLPyHelper(db_type="postgres", ...) as db:
56
+ rows = await db.fetch_all("SELECT * FROM users")
57
+
58
+ Or manage the connection lifecycle manually::
59
+
60
+ db = AsyncSQLPyHelper(db_type="sqlite", database="my.db")
61
+ await db.connect()
62
+ try:
63
+ rows = await db.fetch_all("SELECT * FROM users")
64
+ finally:
65
+ await db.close()
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ db_type: Optional[str] = None,
71
+ host: Optional[str] = None,
72
+ user: Optional[str] = None,
73
+ password: Optional[str] = None,
74
+ database: Optional[str] = None,
75
+ driver: Optional[str] = None,
76
+ port: Optional[str] = None,
77
+ oracle_sid: Optional[str] = None,
78
+ ) -> None:
79
+ self.db_type: str = (db_type or os.getenv("DB_TYPE") or "").lower()
80
+ self.host: Optional[str] = host or os.getenv("DB_HOST")
81
+ self.user: Optional[str] = user or os.getenv("DB_USER")
82
+ self.password: Optional[str] = password or os.getenv("DB_PASSWORD")
83
+ self.database: Optional[str] = database or os.getenv("DB_NAME")
84
+ self.driver: Optional[str] = driver or os.getenv("DB_DRIVER")
85
+ self.port: Optional[str] = port or os.getenv("DB_PORT")
86
+ self.oracle_sid: Optional[str] = oracle_sid or os.getenv("ORACLE_SID")
87
+
88
+ self._connection: Any = None
89
+ self._pool: Any = None
90
+
91
+ if not self.db_type or not self.database:
92
+ raise ValueError("Missing required database configuration.")
93
+
94
+ if self.db_type not in ("sqlite", "postgres", "mysql", "sqlserver", "oracle"):
95
+ raise ValueError(f"Unsupported database type: {self.db_type!r}")
96
+
97
+ # -----------------------------------------------------------------------
98
+ # Connection lifecycle
99
+ # -----------------------------------------------------------------------
100
+
101
+ async def connect(self) -> None:
102
+ """Open the database connection."""
103
+ try:
104
+ if self.db_type == "sqlite":
105
+ import aiosqlite
106
+
107
+ self._connection = await aiosqlite.connect(self.database or "") # type: ignore[arg-type]
108
+ self._connection.row_factory = aiosqlite.Row
109
+ logger.info("Connected to SQLite database: %s", self.database)
110
+
111
+ elif self.db_type == "postgres":
112
+ import asyncpg
113
+
114
+ self._connection = await asyncpg.connect(
115
+ host=self.host,
116
+ port=int(self.port or 5432),
117
+ user=self.user,
118
+ password=self.password,
119
+ database=self.database,
120
+ )
121
+ logger.info("Connected to PostgreSQL database: %s", self.database)
122
+
123
+ elif self.db_type == "mysql":
124
+ import aiomysql
125
+
126
+ self._connection = await aiomysql.connect(
127
+ host=self.host or "localhost",
128
+ port=int(self.port or 3306),
129
+ user=self.user,
130
+ password=self.password or "",
131
+ db=self.database,
132
+ autocommit=False,
133
+ )
134
+ logger.info("Connected to MySQL database: %s", self.database)
135
+
136
+ elif self.db_type == "sqlserver":
137
+ import aioodbc
138
+
139
+ dsn = (
140
+ f"DRIVER={self.driver};"
141
+ f"SERVER={self.host};"
142
+ f"DATABASE={self.database};"
143
+ f"UID={self.user};"
144
+ f"PWD={self.password}"
145
+ )
146
+ self._connection = await aioodbc.connect(dsn=dsn)
147
+ logger.info("Connected to SQL Server database: %s", self.database)
148
+
149
+ elif self.db_type == "oracle":
150
+ import oracledb
151
+
152
+ oracle_port = int(os.getenv("ORACLE_DB_PORT", "1521"))
153
+ dsn = oracledb.makedsn(
154
+ self.host, oracle_port, sid=self.oracle_sid # type: ignore[arg-type]
155
+ )
156
+ self._connection = await oracledb.connect_async(
157
+ user=self.user, password=self.password, dsn=dsn
158
+ )
159
+ logger.info("Connected to Oracle database: %s", self.oracle_sid)
160
+
161
+ except Exception as e:
162
+ raise AsyncConnectionError(
163
+ f"Failed to connect to {self.db_type}: {e}"
164
+ ) from e
165
+
166
+ async def close(self) -> None:
167
+ """Close the database connection."""
168
+ try:
169
+ if self._connection is not None:
170
+ await self._connection.close()
171
+ self._connection = None
172
+ logger.info("Closed %s connection", self.db_type)
173
+ except Exception as e:
174
+ raise AsyncConnectionError(f"Failed to close connection: {e}") from e
175
+
176
+ async def __aenter__(self) -> "AsyncSQLPyHelper":
177
+ await self.connect()
178
+ return self
179
+
180
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
181
+ await self.close()
182
+ return False
183
+
184
+ # -----------------------------------------------------------------------
185
+ # Internal helpers
186
+ # -----------------------------------------------------------------------
187
+
188
+ def _check_connection(self) -> None:
189
+ if self._connection is None:
190
+ raise AsyncConnectionError(
191
+ "No active connection. Call connect() or use async with."
192
+ )
193
+
194
+ def _adapt_query(self, query: str, args: tuple) -> tuple[str, tuple]:
195
+ """
196
+ Adapt a query and its arguments for the active database driver.
197
+
198
+ asyncpg uses $1, $2, ... positional placeholders.
199
+ aiosqlite and aiomysql use ? and %s respectively.
200
+ Callers should write queries using $1, $2, ... style and this
201
+ method will translate as needed.
202
+ """
203
+ if not args:
204
+ return query, args
205
+
206
+ if self.db_type == "postgres":
207
+ # asyncpg natively uses $1, $2 — pass through unchanged
208
+ return query, args
209
+
210
+ elif self.db_type == "sqlite":
211
+ # Replace $1, $2 with ?
212
+ import re
213
+
214
+ adapted = re.sub(r"\$\d+", "?", query)
215
+ return adapted, args
216
+
217
+ elif self.db_type in ("mysql", "sqlserver"):
218
+ # Replace $1, $2 with %s
219
+ import re
220
+
221
+ adapted = re.sub(r"\$\d+", "%s", query)
222
+ return adapted, args
223
+
224
+ elif self.db_type == "oracle":
225
+ # Replace $1, $2 with :1, :2
226
+ import re
227
+
228
+ def replace_placeholder(m: Any) -> str:
229
+ return f":{m.group(0)[1:]}"
230
+
231
+ adapted = re.sub(r"\$(\d+)", replace_placeholder, query)
232
+ return adapted, args
233
+
234
+ return query, args
235
+
236
+ # -----------------------------------------------------------------------
237
+ # Query execution
238
+ # -----------------------------------------------------------------------
239
+
240
+ async def execute(self, query: str, *args: Any) -> None:
241
+ """
242
+ Execute a SQL statement (INSERT, UPDATE, DELETE, DDL).
243
+
244
+ Use $1, $2, ... for parameterised values::
245
+
246
+ await db.execute(
247
+ "INSERT INTO users (id, name) VALUES ($1, $2)",
248
+ 1, "Alice"
249
+ )
250
+
251
+ Args:
252
+ query: SQL query string using $1, $2 placeholders.
253
+ *args: Query parameters.
254
+
255
+ Raises:
256
+ AsyncQueryError: If the query fails.
257
+ """
258
+ self._check_connection()
259
+ adapted_query, adapted_args = self._adapt_query(query, args)
260
+ try:
261
+ if self.db_type == "postgres":
262
+ await self._connection.execute(adapted_query, *adapted_args)
263
+
264
+ elif self.db_type == "sqlite":
265
+ await self._connection.execute(adapted_query, adapted_args)
266
+ await self._connection.commit()
267
+
268
+ elif self.db_type in ("mysql", "sqlserver"):
269
+ async with self._connection.cursor() as cursor:
270
+ await cursor.execute(adapted_query, adapted_args)
271
+ await self._connection.commit()
272
+
273
+ elif self.db_type == "oracle":
274
+ cursor = self._connection.cursor()
275
+ await cursor.execute(adapted_query, adapted_args)
276
+ await self._connection.commit()
277
+
278
+ logger.debug("Executed: %s", query)
279
+
280
+ except Exception as e:
281
+ raise AsyncQueryError(f"Query failed: {e}") from e
282
+
283
+ async def fetch_one(self, query: str, *args: Any) -> Optional[Any]:
284
+ """
285
+ Execute a SELECT query and return a single row, or None.
286
+
287
+ Args:
288
+ query: SQL query string using $1, $2 placeholders.
289
+ *args: Query parameters.
290
+
291
+ Returns:
292
+ A single row, or None if no rows matched.
293
+
294
+ Raises:
295
+ AsyncQueryError: If the query fails.
296
+ """
297
+ self._check_connection()
298
+ adapted_query, adapted_args = self._adapt_query(query, args)
299
+ try:
300
+ if self.db_type == "postgres":
301
+ return await self._connection.fetchrow(adapted_query, *adapted_args)
302
+
303
+ elif self.db_type == "sqlite":
304
+ async with self._connection.execute(
305
+ adapted_query, adapted_args
306
+ ) as cursor:
307
+ return await cursor.fetchone()
308
+
309
+ elif self.db_type in ("mysql", "sqlserver"):
310
+ async with self._connection.cursor() as cursor:
311
+ await cursor.execute(adapted_query, adapted_args)
312
+ return await cursor.fetchone()
313
+
314
+ elif self.db_type == "oracle":
315
+ cursor = self._connection.cursor()
316
+ await cursor.execute(adapted_query, adapted_args)
317
+ return await cursor.fetchone()
318
+
319
+ return None
320
+
321
+ except Exception as e:
322
+ raise AsyncQueryError(f"fetch_one failed: {e}") from e
323
+
324
+ async def fetch_all(self, query: str, *args: Any) -> list[Any]:
325
+ """
326
+ Execute a SELECT query and return all rows.
327
+
328
+ Args:
329
+ query: SQL query string using $1, $2 placeholders.
330
+ *args: Query parameters.
331
+
332
+ Returns:
333
+ A list of rows (empty list if no rows matched).
334
+
335
+ Raises:
336
+ AsyncQueryError: If the query fails.
337
+ """
338
+ self._check_connection()
339
+ adapted_query, adapted_args = self._adapt_query(query, args)
340
+ try:
341
+ if self.db_type == "postgres":
342
+ return await self._connection.fetch(adapted_query, *adapted_args)
343
+
344
+ elif self.db_type == "sqlite":
345
+ async with self._connection.execute(
346
+ adapted_query, adapted_args
347
+ ) as cursor:
348
+ return await cursor.fetchall()
349
+
350
+ elif self.db_type in ("mysql", "sqlserver"):
351
+ async with self._connection.cursor() as cursor:
352
+ await cursor.execute(adapted_query, adapted_args)
353
+ return await cursor.fetchall()
354
+
355
+ elif self.db_type == "oracle":
356
+ cursor = self._connection.cursor()
357
+ await cursor.execute(adapted_query, adapted_args)
358
+ return await cursor.fetchall()
359
+
360
+ return []
361
+
362
+ except Exception as e:
363
+ raise AsyncQueryError(f"fetch_all failed: {e}") from e
364
+
365
+ async def fetch_val(self, query: str, *args: Any) -> Optional[Any]:
366
+ """
367
+ Execute a SELECT query and return a single scalar value.
368
+
369
+ Useful for COUNT, SUM, or any query returning one value::
370
+
371
+ count = await db.fetch_val("SELECT COUNT(*) FROM users")
372
+
373
+ Args:
374
+ query: SQL query string using $1, $2 placeholders.
375
+ *args: Query parameters.
376
+
377
+ Returns:
378
+ A single scalar value, or None.
379
+
380
+ Raises:
381
+ AsyncQueryError: If the query fails.
382
+ """
383
+ self._check_connection()
384
+ adapted_query, adapted_args = self._adapt_query(query, args)
385
+ try:
386
+ if self.db_type == "postgres":
387
+ return await self._connection.fetchval(adapted_query, *adapted_args)
388
+
389
+ elif self.db_type == "sqlite":
390
+ async with self._connection.execute(
391
+ adapted_query, adapted_args
392
+ ) as cursor:
393
+ row = await cursor.fetchone()
394
+ return row[0] if row else None
395
+
396
+ elif self.db_type in ("mysql", "sqlserver"):
397
+ async with self._connection.cursor() as cursor:
398
+ await cursor.execute(adapted_query, adapted_args)
399
+ row = await cursor.fetchone()
400
+ return row[0] if row else None
401
+
402
+ elif self.db_type == "oracle":
403
+ cursor = self._connection.cursor()
404
+ await cursor.execute(adapted_query, adapted_args)
405
+ row = await cursor.fetchone()
406
+ return row[0] if row else None
407
+
408
+ return None
409
+
410
+ except Exception as e:
411
+ raise AsyncQueryError(f"fetch_val failed: {e}") from e
412
+
413
+ async def execute_many(self, query: str, args_list: list[tuple]) -> None:
414
+ """
415
+ Execute a SQL statement multiple times with different parameters.
416
+ Efficient for bulk inserts::
417
+
418
+ await db.execute_many(
419
+ "INSERT INTO users (id, name) VALUES ($1, $2)",
420
+ [(1, "Alice"), (2, "Bob"), (3, "Charlie")]
421
+ )
422
+
423
+ Args:
424
+ query: SQL query string using $1, $2 placeholders.
425
+ args_list: List of parameter tuples.
426
+
427
+ Raises:
428
+ AsyncQueryError: If the operation fails.
429
+ """
430
+ self._check_connection()
431
+ if not args_list:
432
+ return
433
+ try:
434
+ if self.db_type == "postgres":
435
+ await self._connection.executemany(query, args_list)
436
+
437
+ elif self.db_type == "sqlite":
438
+ import re
439
+
440
+ adapted = re.sub(r"\$\d+", "?", query)
441
+ await self._connection.executemany(adapted, args_list)
442
+ await self._connection.commit()
443
+
444
+ elif self.db_type in ("mysql", "sqlserver"):
445
+ import re
446
+
447
+ adapted = re.sub(r"\$\d+", "%s", query)
448
+ async with self._connection.cursor() as cursor:
449
+ await cursor.executemany(adapted, args_list)
450
+ await self._connection.commit()
451
+
452
+ elif self.db_type == "oracle":
453
+ import re
454
+
455
+ def replace_placeholder(m: Any) -> str:
456
+ return f":{m.group(1)}"
457
+
458
+ adapted = re.sub(r"\$(\d+)", replace_placeholder, query)
459
+ cursor = self._connection.cursor()
460
+ await cursor.executemany(adapted, args_list)
461
+ await self._connection.commit()
462
+
463
+ logger.debug("execute_many: %d rows", len(args_list))
464
+
465
+ except Exception as e:
466
+ raise AsyncQueryError(f"execute_many failed: {e}") from e
467
+
468
+ # -----------------------------------------------------------------------
469
+ # Transaction management
470
+ # -----------------------------------------------------------------------
471
+
472
+ async def begin_transaction(self) -> None:
473
+ """
474
+ Begin an explicit transaction.
475
+
476
+ For PostgreSQL, use the transaction() context manager instead,
477
+ which is the idiomatic asyncpg approach.
478
+
479
+ Raises:
480
+ AsyncQueryError: If the transaction cannot be started.
481
+ """
482
+ self._check_connection()
483
+ try:
484
+ if self.db_type == "sqlite":
485
+ await self._connection.execute("BEGIN")
486
+ elif self.db_type == "mysql":
487
+ await self._connection.begin()
488
+ elif self.db_type == "sqlserver":
489
+ async with self._connection.cursor() as cursor:
490
+ await cursor.execute("BEGIN TRANSACTION")
491
+ elif self.db_type == "oracle":
492
+ pass # Oracle starts transactions implicitly
493
+ elif self.db_type == "postgres":
494
+ # asyncpg transactions are managed via connection.transaction()
495
+ # Calling begin() manually is supported but the context manager
496
+ # is preferred — see transaction() below
497
+ self._pg_transaction = self._connection.transaction()
498
+ await self._pg_transaction.start()
499
+ logger.info("Transaction started on %s", self.db_type)
500
+ except Exception as e:
501
+ raise AsyncQueryError(f"Failed to begin transaction: {e}") from e
502
+
503
+ async def commit_transaction(self) -> None:
504
+ """Commit the current transaction."""
505
+ self._check_connection()
506
+ try:
507
+ if self.db_type == "postgres":
508
+ await self._pg_transaction.commit()
509
+ else:
510
+ await self._connection.commit()
511
+ logger.info("Transaction committed on %s", self.db_type)
512
+ except Exception as e:
513
+ raise AsyncQueryError(f"Failed to commit transaction: {e}") from e
514
+
515
+ async def rollback_transaction(self) -> None:
516
+ """Roll back the current transaction."""
517
+ self._check_connection()
518
+ try:
519
+ if self.db_type == "postgres":
520
+ await self._pg_transaction.rollback()
521
+ else:
522
+ await self._connection.rollback()
523
+ logger.info("Transaction rolled back on %s", self.db_type)
524
+ except Exception as e:
525
+ raise AsyncQueryError(f"Failed to rollback transaction: {e}") from e
526
+
527
+ # -----------------------------------------------------------------------
528
+ # Connection pooling
529
+ # -----------------------------------------------------------------------
530
+
531
+ async def setup_pool(self, min_size: int = 1, max_size: int = 10) -> None:
532
+ """
533
+ Set up an async connection pool.
534
+
535
+ Supported for PostgreSQL and MySQL only.
536
+ After calling this, use get_connection_from_pool() to acquire
537
+ connections.
538
+
539
+ Args:
540
+ min_size: Minimum number of connections in the pool.
541
+ max_size: Maximum number of connections in the pool.
542
+
543
+ Raises:
544
+ AsyncConnectionError: If pool setup fails or db_type
545
+ does not support pooling.
546
+ """
547
+ try:
548
+ if self.db_type == "postgres":
549
+ import asyncpg
550
+
551
+ self._pool = await asyncpg.create_pool(
552
+ host=self.host,
553
+ port=int(self.port or 5432),
554
+ user=self.user,
555
+ password=self.password,
556
+ database=self.database,
557
+ min_size=min_size,
558
+ max_size=max_size,
559
+ )
560
+ logger.info(
561
+ "PostgreSQL async pool created (min=%d, max=%d)", min_size, max_size
562
+ )
563
+
564
+ elif self.db_type == "mysql":
565
+ import aiomysql
566
+
567
+ self._pool = await aiomysql.create_pool(
568
+ host=self.host or "localhost",
569
+ port=int(self.port or 3306),
570
+ user=self.user,
571
+ password=self.password or "",
572
+ db=self.database,
573
+ minsize=min_size,
574
+ maxsize=max_size,
575
+ )
576
+ logger.info(
577
+ "MySQL async pool created (min=%d, max=%d)", min_size, max_size
578
+ )
579
+
580
+ else:
581
+ raise AsyncConnectionError(
582
+ f"Async connection pooling not supported for {self.db_type!r}. "
583
+ "Supported: postgres, mysql."
584
+ )
585
+ except AsyncConnectionError:
586
+ raise
587
+ except Exception as e:
588
+ raise AsyncConnectionError(f"Failed to create async pool: {e}") from e
589
+
590
+ async def close_pool(self) -> None:
591
+ """Close the async connection pool."""
592
+ if self._pool is not None:
593
+ if self.db_type == "mysql":
594
+ self._pool.close()
595
+ await self._pool.wait_closed()
596
+ else:
597
+ await self._pool.close()
598
+ self._pool = None
599
+ logger.info("Async pool closed for %s", self.db_type)
@@ -0,0 +1,382 @@
1
+ """
2
+ sqlpyhelper.migration
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+ Cross-database table migration utilities.
5
+
6
+ Copies data (and optionally schema) from one database to another.
7
+ Supports SQLite, PostgreSQL, MySQL, SQL Server, and Oracle.
8
+
9
+ Example usage::
10
+
11
+ from sqlpyhelper.db_helper import SQLPyHelper
12
+ from sqlpyhelper.migration import migrate_table
13
+
14
+ with SQLPyHelper(db_type="sqlite", database="local.db") as source:
15
+ with SQLPyHelper(db_type="postgres", host="localhost",
16
+ user="user", password="pass",
17
+ database="mydb") as target:
18
+
19
+ migrate_table(
20
+ source=source,
21
+ target=target,
22
+ table="users",
23
+ )
24
+ """
25
+
26
+ import logging
27
+ from typing import Any, Optional
28
+
29
+ logger = logging.getLogger("sqlpyhelper.migration")
30
+
31
+
32
+ class MigrationError(Exception):
33
+ """Raised when a migration operation fails."""
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Type mapping
38
+ # ---------------------------------------------------------------------------
39
+
40
+ # Maps (source_db_type, generic_type) -> target SQL type string.
41
+ # Generic types are normalised from the source cursor description.
42
+
43
+
44
+ _TYPE_MAP: dict[str, dict[str, str]] = {
45
+ "sqlite": {
46
+ "integer": "INTEGER",
47
+ "real": "REAL",
48
+ "text": "TEXT",
49
+ "blob": "BLOB",
50
+ "numeric": "NUMERIC",
51
+ },
52
+ "postgres": {
53
+ "integer": "INTEGER",
54
+ "real": "DOUBLE PRECISION",
55
+ "text": "TEXT",
56
+ "blob": "BYTEA",
57
+ "numeric": "NUMERIC",
58
+ "varchar": "TEXT",
59
+ "bool": "BOOLEAN",
60
+ "date": "DATE",
61
+ "timestamp": "TIMESTAMP",
62
+ },
63
+ "mysql": {
64
+ "integer": "INT",
65
+ "real": "DOUBLE",
66
+ "text": "TEXT",
67
+ "blob": "BLOB",
68
+ "numeric": "DECIMAL",
69
+ "varchar": "VARCHAR(255)",
70
+ "bool": "TINYINT(1)",
71
+ "date": "DATE",
72
+ "timestamp": "DATETIME",
73
+ },
74
+ "sqlserver": {
75
+ "integer": "INT",
76
+ "real": "FLOAT",
77
+ "text": "NVARCHAR(MAX)",
78
+ "blob": "VARBINARY(MAX)",
79
+ "numeric": "DECIMAL",
80
+ "varchar": "NVARCHAR(255)",
81
+ "bool": "BIT",
82
+ "date": "DATE",
83
+ "timestamp": "DATETIME2",
84
+ },
85
+ "oracle": {
86
+ "integer": "NUMBER",
87
+ "real": "FLOAT",
88
+ "text": "CLOB",
89
+ "blob": "BLOB",
90
+ "numeric": "NUMBER",
91
+ "varchar": "VARCHAR2(255)",
92
+ "bool": "NUMBER(1)",
93
+ "date": "DATE",
94
+ "timestamp": "TIMESTAMP",
95
+ },
96
+ }
97
+
98
+
99
+ def _normalise_type(raw_type: Optional[str]) -> str:
100
+ """
101
+ Normalise a raw column type string from a cursor description
102
+ into a generic type key used for cross-database mapping.
103
+ """
104
+ if raw_type is None:
105
+ return "text"
106
+ t = raw_type.lower().split("(")[0].strip()
107
+ if t in (
108
+ "int",
109
+ "integer",
110
+ "int4",
111
+ "int8",
112
+ "bigint",
113
+ "smallint",
114
+ "tinyint",
115
+ "number",
116
+ ):
117
+ return "integer"
118
+ if t in ("real", "float", "double", "double precision", "float4", "float8"):
119
+ return "real"
120
+ if t in (
121
+ "text",
122
+ "clob",
123
+ "nvarchar",
124
+ "nvarchar2",
125
+ "ntext",
126
+ "longtext",
127
+ "mediumtext",
128
+ ):
129
+ return "text"
130
+ if t in ("varchar", "varchar2", "character varying", "char", "nchar"):
131
+ return "varchar"
132
+ if t in ("blob", "bytea", "varbinary", "binary", "longblob", "mediumblob"):
133
+ return "blob"
134
+ if t in ("numeric", "decimal"):
135
+ return "numeric"
136
+ if t in ("bool", "boolean"):
137
+ return "bool"
138
+ if t in ("date",):
139
+ return "date"
140
+ if t in ("timestamp", "datetime", "datetime2", "timestamp without time zone"):
141
+ return "timestamp"
142
+ return "text" # safe fallback
143
+
144
+
145
+ def _map_type(raw_type: Optional[str], target_db: str) -> str:
146
+ """Map a source column type to the appropriate type for the target database."""
147
+ generic = _normalise_type(raw_type)
148
+ target_types = _TYPE_MAP.get(target_db, _TYPE_MAP["sqlite"])
149
+ return target_types.get(generic, "TEXT")
150
+
151
+
152
+ def _get_column_info(source: Any, table: str) -> list[tuple[str, str]]:
153
+ """
154
+ Return a list of (column_name, raw_type_string) tuples
155
+ by inspecting the source database schema.
156
+ """
157
+ db_type = source.db_type
158
+
159
+ if db_type == "sqlite":
160
+ source.execute_query(f"PRAGMA table_info({table})")
161
+ rows = source.fetch_all()
162
+ # PRAGMA table_info returns: (cid, name, type, notnull, dflt_value, pk)
163
+ return [(row[1], row[2]) for row in rows]
164
+
165
+ elif db_type == "postgres":
166
+ source.execute_query(
167
+ """
168
+ SELECT column_name, data_type
169
+ FROM information_schema.columns
170
+ WHERE table_name = %s
171
+ ORDER BY ordinal_position
172
+ """,
173
+ (table,),
174
+ )
175
+ return source.fetch_all()
176
+
177
+ elif db_type == "mysql":
178
+ source.execute_query(
179
+ """
180
+ SELECT column_name, data_type
181
+ FROM information_schema.columns
182
+ WHERE table_name = %s AND table_schema = DATABASE()
183
+ ORDER BY ordinal_position
184
+ """,
185
+ (table,),
186
+ )
187
+ return source.fetch_all()
188
+
189
+ elif db_type == "sqlserver":
190
+ source.execute_query(
191
+ """
192
+ SELECT column_name, data_type
193
+ FROM information_schema.columns
194
+ WHERE table_name = %s
195
+ ORDER BY ordinal_position
196
+ """,
197
+ (table,),
198
+ )
199
+ return source.fetch_all()
200
+
201
+ elif db_type == "oracle":
202
+ source.execute_query(
203
+ """
204
+ SELECT column_name, data_type
205
+ FROM user_tab_columns
206
+ WHERE table_name = UPPER(:1)
207
+ ORDER BY column_id
208
+ """,
209
+ (table,),
210
+ )
211
+ return source.fetch_all()
212
+
213
+ else:
214
+ raise MigrationError(f"Cannot inspect schema for db_type={db_type!r}")
215
+
216
+
217
+ def _build_create_table_sql(
218
+ table: str,
219
+ columns: list[tuple[str, str]],
220
+ target_db: str,
221
+ ) -> str:
222
+ """Build a CREATE TABLE IF NOT EXISTS statement for the target database."""
223
+ col_defs = ", ".join(
224
+ f"{col_name} {_map_type(col_type, target_db)}" for col_name, col_type in columns
225
+ )
226
+ return f"CREATE TABLE IF NOT EXISTS {table} ({col_defs})"
227
+
228
+
229
+ def _build_insert_sql(
230
+ table: str,
231
+ column_names: list[str],
232
+ target_db: str,
233
+ ) -> str:
234
+ """Build a parameterised INSERT statement for the target database."""
235
+ cols = ", ".join(column_names)
236
+ placeholder = "?" if target_db == "sqlite" else "%s"
237
+ placeholders = ", ".join([placeholder] * len(column_names))
238
+ return f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Public API
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def migrate_table(
247
+ source: Any,
248
+ target: Any,
249
+ table: str,
250
+ create_table: bool = True,
251
+ batch_size: int = 500,
252
+ truncate_target: bool = False,
253
+ ) -> dict[str, Any]:
254
+ """
255
+ Migrate a table from one database to another.
256
+
257
+ Copies all rows from ``source`` to ``target``. Optionally creates
258
+ the target table using best-effort type mapping from the source schema.
259
+
260
+ Args:
261
+ source: A connected SQLPyHelper instance (the data source).
262
+ target: A connected SQLPyHelper instance (the destination).
263
+ table: Name of the table to migrate.
264
+ create_table: If True, creates the table in the target database
265
+ using best-effort type mapping. If False, the table
266
+ must already exist in the target. Default: True.
267
+ batch_size: Number of rows to insert per batch. Default: 500.
268
+ truncate_target: If True, deletes all existing rows in the target
269
+ table before inserting. Default: False.
270
+
271
+ Returns:
272
+ A dict with migration statistics::
273
+
274
+ {
275
+ "table": "users",
276
+ "rows_migrated": 1234,
277
+ "batches": 3,
278
+ "source_db": "sqlite",
279
+ "target_db": "postgres",
280
+ }
281
+
282
+ Raises:
283
+ MigrationError: If the migration fails for any reason.
284
+
285
+ Example::
286
+
287
+ from sqlpyhelper.db_helper import SQLPyHelper
288
+ from sqlpyhelper.migration import migrate_table
289
+
290
+ with SQLPyHelper(db_type="sqlite", database="local.db") as source:
291
+ with SQLPyHelper(db_type="postgres", host="localhost",
292
+ user="user", password="pass",
293
+ database="mydb") as target:
294
+
295
+ stats = migrate_table(
296
+ source=source,
297
+ target=target,
298
+ table="users",
299
+ create_table=True,
300
+ batch_size=1000,
301
+ )
302
+ print(f"Migrated {stats['rows_migrated']} rows")
303
+ """
304
+ source_db = source.db_type
305
+ target_db = target.db_type
306
+
307
+ logger.info(
308
+ "Starting migration of table '%s' from %s -> %s",
309
+ table,
310
+ source_db,
311
+ target_db,
312
+ )
313
+
314
+ try:
315
+ # Step 1 — fetch column info from source
316
+ columns = _get_column_info(source, table)
317
+ if not columns:
318
+ raise MigrationError(
319
+ f"Table '{table}' not found in source database " f"or has no columns."
320
+ )
321
+ column_names = [col[0] for col in columns]
322
+ logger.info("Found %d columns: %s", len(columns), column_names)
323
+
324
+ # Step 2 — optionally create the table in the target
325
+ if create_table:
326
+ ddl = _build_create_table_sql(table, columns, target_db)
327
+ logger.info("Creating target table: %s", ddl)
328
+ target.execute_query(ddl)
329
+
330
+ # Step 3 — optionally truncate the target table
331
+ if truncate_target:
332
+ if target_db == "sqlite":
333
+ target.execute_query(f"DELETE FROM {table}")
334
+ else:
335
+ target.execute_query(f"TRUNCATE TABLE {table}")
336
+ logger.info("Truncated target table '%s'", table)
337
+
338
+ # Step 4 — fetch all rows from source
339
+ source.execute_query(f"SELECT * FROM {table}")
340
+ all_rows = source.fetch_all()
341
+ total_rows = len(all_rows)
342
+ logger.info("Fetched %d rows from source", total_rows)
343
+
344
+ if total_rows == 0:
345
+ logger.info("Source table '%s' is empty — nothing to migrate", table)
346
+ return {
347
+ "table": table,
348
+ "rows_migrated": 0,
349
+ "batches": 0,
350
+ "source_db": source_db,
351
+ "target_db": target_db,
352
+ }
353
+
354
+ # Step 5 — insert in batches
355
+ insert_sql = _build_insert_sql(table, column_names, target_db)
356
+ batches = 0
357
+ for i in range(0, total_rows, batch_size):
358
+ batch = all_rows[i : i + batch_size]
359
+ target.cursor.executemany(insert_sql, batch)
360
+ target.connection.commit()
361
+ batches += 1
362
+ logger.info(
363
+ "Inserted batch %d (%d/%d rows)",
364
+ batches,
365
+ min(i + batch_size, total_rows),
366
+ total_rows,
367
+ )
368
+
369
+ logger.info("Migration complete: %d rows in %d batches", total_rows, batches)
370
+
371
+ return {
372
+ "table": table,
373
+ "rows_migrated": total_rows,
374
+ "batches": batches,
375
+ "source_db": source_db,
376
+ "target_db": target_db,
377
+ }
378
+
379
+ except MigrationError:
380
+ raise
381
+ except Exception as e:
382
+ raise MigrationError(f"Migration of '{table}' failed: {e}") from e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SQLPyHelper
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: A simple SQL database helper package for Python.
5
5
  Home-page: https://github.com/adebayopeter/sqlpyhelper
6
6
  Author: Adebayo Olaonipekun
@@ -34,11 +34,28 @@ Provides-Extra: sqlserver
34
34
  Requires-Dist: pyodbc; extra == "sqlserver"
35
35
  Provides-Extra: oracle
36
36
  Requires-Dist: oracledb; extra == "oracle"
37
+ Provides-Extra: async-postgres
38
+ Requires-Dist: asyncpg; extra == "async-postgres"
39
+ Provides-Extra: async-mysql
40
+ Requires-Dist: aiomysql; extra == "async-mysql"
41
+ Provides-Extra: async-sqlite
42
+ Requires-Dist: aiosqlite; extra == "async-sqlite"
43
+ Provides-Extra: async-sqlserver
44
+ Requires-Dist: aioodbc; extra == "async-sqlserver"
45
+ Provides-Extra: async-all
46
+ Requires-Dist: asyncpg; extra == "async-all"
47
+ Requires-Dist: aiomysql; extra == "async-all"
48
+ Requires-Dist: aiosqlite; extra == "async-all"
49
+ Requires-Dist: aioodbc; extra == "async-all"
37
50
  Provides-Extra: all
38
51
  Requires-Dist: psycopg2; extra == "all"
39
52
  Requires-Dist: mysql-connector-python; extra == "all"
40
53
  Requires-Dist: pyodbc; extra == "all"
41
54
  Requires-Dist: oracledb; extra == "all"
55
+ Requires-Dist: asyncpg; extra == "all"
56
+ Requires-Dist: aiomysql; extra == "all"
57
+ Requires-Dist: aiosqlite; extra == "all"
58
+ Requires-Dist: aioodbc; extra == "all"
42
59
  Dynamic: author
43
60
  Dynamic: author-email
44
61
  Dynamic: classifier
@@ -56,11 +73,11 @@ Dynamic: summary
56
73
  # SQLPyHelper
57
74
 
58
75
  [![PyPI version](https://img.shields.io/pypi/v/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
76
+ [![Documentation](https://readthedocs.org/projects/sqlpyhelper/badge/?version=latest)](https://sqlpyhelper.readthedocs.io/en/latest/)
59
77
  [![PyPI downloads](https://img.shields.io/pypi/dm/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
60
78
  [![Python versions](https://img.shields.io/pypi/pyversions/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
61
79
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/adebayopeter/sqlpyhelper/blob/main/LICENSE)
62
80
  [![GitHub stars](https://img.shields.io/github/stars/adebayopeter/sqlpyhelper?style=social)](https://github.com/adebayopeter/sqlpyhelper)
63
- [![Documentation](https://readthedocs.org/projects/sqlpyhelper/badge/?version=latest)](https://sqlpyhelper.readthedocs.io/en/latest/)
64
81
 
65
82
  SQLPyHelper is a lightweight Python library that gives you a single, consistent API across **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** — without the overhead of an ORM.
66
83
 
@@ -0,0 +1,13 @@
1
+ sqlpyhelper/__init__.py,sha256=UN7k9UyQVsxtFLDH496vDYQdGOIa525T4lGvVXjRM8s,370
2
+ sqlpyhelper/async_helper.py,sha256=_R01cUZSj_q9QH4pGT30gh56GJGSfgi0hIhgDJqmyYw,21534
3
+ sqlpyhelper/automation_utils.py,sha256=pC6pH6bJ-k8iPVeHJ4gUiwEe822dasmKg53ya9bMxyE,5381
4
+ sqlpyhelper/cli.py,sha256=yj0kWJu3oh_JLnmi0L7a5ing2_0x4CQGOKSOhZLAtoY,5646
5
+ sqlpyhelper/db_helper.py,sha256=4DbdBVo86zz1d0hNHtSc4b3Tks7bJGTMTyabsydQyOE,14191
6
+ sqlpyhelper/migration.py,sha256=byAn7ToVgIB8tl1N39DB0MbHigjH2l-qX7QSskgzzTg,11673
7
+ sqlpyhelper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ sqlpyhelper-0.1.8.dist-info/licenses/LICENSE,sha256=9XzXxZ_8mWFM9-2TlqyE3L69zvRf4VPY_xIzSj5iU-g,1076
9
+ sqlpyhelper-0.1.8.dist-info/METADATA,sha256=l-kKKsEjezQU1GjTsXHFeRpDAjiPfK-5AIlepQ396Wk,10450
10
+ sqlpyhelper-0.1.8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ sqlpyhelper-0.1.8.dist-info/entry_points.txt,sha256=uAzSqwkAbbJqQUKHlPNwOebTJVA0FqkOvn2CRP6xSz8,52
12
+ sqlpyhelper-0.1.8.dist-info/top_level.txt,sha256=FrLqTmqTGDa8jHnnf2ZVkYO-gFvLXX9QonpUCE6wKGs,12
13
+ sqlpyhelper-0.1.8.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- sqlpyhelper/__init__.py,sha256=7hQ4FRKUaUU_TiGuU1cldnvTmG4S2riZP43SqxA4fUI,183
2
- sqlpyhelper/automation_utils.py,sha256=pC6pH6bJ-k8iPVeHJ4gUiwEe822dasmKg53ya9bMxyE,5381
3
- sqlpyhelper/cli.py,sha256=yj0kWJu3oh_JLnmi0L7a5ing2_0x4CQGOKSOhZLAtoY,5646
4
- sqlpyhelper/db_helper.py,sha256=4DbdBVo86zz1d0hNHtSc4b3Tks7bJGTMTyabsydQyOE,14191
5
- sqlpyhelper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- sqlpyhelper-0.1.6.dist-info/licenses/LICENSE,sha256=9XzXxZ_8mWFM9-2TlqyE3L69zvRf4VPY_xIzSj5iU-g,1076
7
- sqlpyhelper-0.1.6.dist-info/METADATA,sha256=JA0zdZYH5pceNjom3LQQPuUhfGe9kH_6RjXBrKiOi4A,9763
8
- sqlpyhelper-0.1.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
- sqlpyhelper-0.1.6.dist-info/entry_points.txt,sha256=uAzSqwkAbbJqQUKHlPNwOebTJVA0FqkOvn2CRP6xSz8,52
10
- sqlpyhelper-0.1.6.dist-info/top_level.txt,sha256=FrLqTmqTGDa8jHnnf2ZVkYO-gFvLXX9QonpUCE6wKGs,12
11
- sqlpyhelper-0.1.6.dist-info/RECORD,,