SQLPyHelper 0.1.6__py3-none-any.whl → 0.1.7__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,5 +1,5 @@
1
1
  # Match the version in setup.py
2
- __version__ = "0.1.6"
2
+ __version__ = "0.1.7"
3
3
 
4
4
  from sqlpyhelper.db_helper import ( # noqa: F401
5
5
  BackupError,
@@ -7,3 +7,4 @@ from sqlpyhelper.db_helper import ( # noqa: F401
7
7
  QueryError,
8
8
  SQLPyHelperError,
9
9
  )
10
+ from sqlpyhelper.migration import MigrationError # noqa: F401
@@ -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.7
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
@@ -56,11 +56,11 @@ Dynamic: summary
56
56
  # SQLPyHelper
57
57
 
58
58
  [![PyPI version](https://img.shields.io/pypi/v/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
59
+ [![Documentation](https://readthedocs.org/projects/sqlpyhelper/badge/?version=latest)](https://sqlpyhelper.readthedocs.io/en/latest/)
59
60
  [![PyPI downloads](https://img.shields.io/pypi/dm/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
60
61
  [![Python versions](https://img.shields.io/pypi/pyversions/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
61
62
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/adebayopeter/sqlpyhelper/blob/main/LICENSE)
62
63
  [![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
64
 
65
65
  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
66
 
@@ -0,0 +1,12 @@
1
+ sqlpyhelper/__init__.py,sha256=wiwMpojvoi0j_qunbTQgMfyxzzIsHMkmDr1ADLyhvfY,246
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/migration.py,sha256=byAn7ToVgIB8tl1N39DB0MbHigjH2l-qX7QSskgzzTg,11673
6
+ sqlpyhelper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ sqlpyhelper-0.1.7.dist-info/licenses/LICENSE,sha256=9XzXxZ_8mWFM9-2TlqyE3L69zvRf4VPY_xIzSj5iU-g,1076
8
+ sqlpyhelper-0.1.7.dist-info/METADATA,sha256=C2jJX7sEHHqLnQxL5iZxiG8on4xCDHpEu8_8zuaZWlk,9763
9
+ sqlpyhelper-0.1.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ sqlpyhelper-0.1.7.dist-info/entry_points.txt,sha256=uAzSqwkAbbJqQUKHlPNwOebTJVA0FqkOvn2CRP6xSz8,52
11
+ sqlpyhelper-0.1.7.dist-info/top_level.txt,sha256=FrLqTmqTGDa8jHnnf2ZVkYO-gFvLXX9QonpUCE6wKGs,12
12
+ sqlpyhelper-0.1.7.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,,