tablemaster 2.1.9__tar.gz → 2.1.10__tar.gz

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.
Files changed (38) hide show
  1. {tablemaster-2.1.9 → tablemaster-2.1.10}/PKG-INFO +1 -1
  2. {tablemaster-2.1.9 → tablemaster-2.1.10}/pyproject.toml +1 -1
  3. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/database.py +44 -8
  4. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/PKG-INFO +1 -1
  5. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/SOURCES.txt +1 -0
  6. tablemaster-2.1.10/tests/test_postgresql_upsert.py +63 -0
  7. {tablemaster-2.1.9 → tablemaster-2.1.10}/LICENSE +0 -0
  8. {tablemaster-2.1.9 → tablemaster-2.1.10}/README.md +0 -0
  9. {tablemaster-2.1.9 → tablemaster-2.1.10}/setup.cfg +0 -0
  10. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/__init__.py +0 -0
  11. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/__main__.py +0 -0
  12. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/cli.py +0 -0
  13. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/config.py +0 -0
  14. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/feishu.py +0 -0
  15. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/gspread.py +0 -0
  16. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/local.py +0 -0
  17. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/__init__.py +0 -0
  18. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/apply.py +0 -0
  19. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/__init__.py +0 -0
  20. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/base.py +0 -0
  21. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/mysql.py +0 -0
  22. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/postgresql.py +0 -0
  23. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/tidb.py +0 -0
  24. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/diff.py +0 -0
  25. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/init.py +0 -0
  26. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/introspect.py +0 -0
  27. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/loader.py +0 -0
  28. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/models.py +0 -0
  29. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/plan.py +0 -0
  30. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/pull.py +0 -0
  31. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/sync.py +0 -0
  32. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/utils.py +0 -0
  33. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/dependency_links.txt +0 -0
  34. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/entry_points.txt +0 -0
  35. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/requires.txt +0 -0
  36. {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/top_level.txt +0 -0
  37. {tablemaster-2.1.9 → tablemaster-2.1.10}/tests/test_error_visibility.py +0 -0
  38. {tablemaster-2.1.9 → tablemaster-2.1.10}/tests/test_schema_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablemaster
3
- Version: 2.1.9
3
+ Version: 2.1.10
4
4
  Summary: tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API.
5
5
  Author-email: Livid <livid.su@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ilivid/tablemaster
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tablemaster"
7
- version = "2.1.9"
7
+ version = "2.1.10"
8
8
  description = "tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -130,6 +130,19 @@ def _safe_identifier(identifier: str) -> str:
130
130
  return identifier
131
131
 
132
132
 
133
+ def _safe_postgresql_table(table: str) -> str:
134
+ return '.'.join(_safe_identifier(part.strip()) for part in table.split('.'))
135
+
136
+
137
+ def _execute_postgresql_values(connection: Any, sql: str, rows: List[Tuple[Any, ...]], page_size: int) -> None:
138
+ from psycopg2.extras import execute_values
139
+
140
+ proxied_connection = connection.connection
141
+ dbapi_connection = getattr(proxied_connection, 'driver_connection', proxied_connection)
142
+ with dbapi_connection.cursor() as cursor:
143
+ execute_values(cursor, sql, rows, page_size=page_size)
144
+
145
+
133
146
  def _safe_mysql_type(data_type: str) -> str:
134
147
  """
135
148
  Ensure a MySQL data type expression is safe from SQL injection.
@@ -383,6 +396,7 @@ class ManageTable:
383
396
 
384
397
  safe_keys = [_safe_identifier(k) for k in keys]
385
398
  safe_columns = [_safe_identifier(col) for col in columns]
399
+ safe_table = _safe_postgresql_table(self.table)
386
400
  quoted_columns = ', '.join([f'"{col}"' for col in safe_columns])
387
401
  update_columns = ', '.join(
388
402
  [f'"{col}"=EXCLUDED."{col}"' for col in safe_columns if col not in safe_keys]
@@ -391,23 +405,45 @@ class ManageTable:
391
405
 
392
406
  if update_columns:
393
407
  insert_sql = f"""
394
- INSERT INTO {self.table} ({quoted_columns})
395
- VALUES ({value_placeholders})
408
+ INSERT INTO {safe_table} ({quoted_columns})
409
+ VALUES %s
396
410
  ON CONFLICT ({conflict_keys_str}) DO UPDATE SET {update_columns}
397
411
  """
398
412
  else:
399
413
  insert_sql = f"""
400
- INSERT INTO {self.table} ({quoted_columns})
401
- VALUES ({value_placeholders})
414
+ INSERT INTO {safe_table} ({quoted_columns})
415
+ VALUES %s
402
416
  ON CONFLICT ({conflict_keys_str}) DO NOTHING
403
417
  """
418
+ data_frame = chunk.astype(object).where(pd.notna(chunk), None)
419
+ data = [tuple(row) for row in data_frame.itertuples(index=False, name=None)]
420
+ _execute_postgresql_values(connection, insert_sql, data, page_size=chunk_size)
421
+ pbar.update(1)
422
+ continue
404
423
  else:
405
424
  raise ValueError(f'Unsupported db_type for upsert: {db_type}')
406
425
  else:
407
- insert_sql = f"""
408
- INSERT IGNORE INTO {self.table} ({', '.join([f'`{col}`' for col in columns])})
409
- VALUES ({value_placeholders})
410
- """
426
+ if db_type in ('mysql', 'tidb'):
427
+ insert_sql = f"""
428
+ INSERT IGNORE INTO {self.table} ({', '.join([f'`{col}`' for col in columns])})
429
+ VALUES ({value_placeholders})
430
+ """
431
+ elif db_type == 'postgresql':
432
+ safe_columns = [_safe_identifier(col) for col in columns]
433
+ safe_table = _safe_postgresql_table(self.table)
434
+ quoted_columns = ', '.join([f'"{col}"' for col in safe_columns])
435
+ insert_sql = f"""
436
+ INSERT INTO {safe_table} ({quoted_columns})
437
+ VALUES %s
438
+ ON CONFLICT DO NOTHING
439
+ """
440
+ data_frame = chunk.astype(object).where(pd.notna(chunk), None)
441
+ data = [tuple(row) for row in data_frame.itertuples(index=False, name=None)]
442
+ _execute_postgresql_values(connection, insert_sql, data, page_size=chunk_size)
443
+ pbar.update(1)
444
+ continue
445
+ else:
446
+ raise ValueError(f'Unsupported db_type for upsert: {db_type}')
411
447
 
412
448
  data = chunk.astype(object).where(pd.notna(chunk), None).to_dict(orient='records')
413
449
  connection.execute(text(insert_sql), data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablemaster
3
- Version: 2.1.9
3
+ Version: 2.1.10
4
4
  Summary: tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API.
5
5
  Author-email: Livid <livid.su@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ilivid/tablemaster
@@ -32,4 +32,5 @@ tablemaster/schema/dialects/mysql.py
32
32
  tablemaster/schema/dialects/postgresql.py
33
33
  tablemaster/schema/dialects/tidb.py
34
34
  tests/test_error_visibility.py
35
+ tests/test_postgresql_upsert.py
35
36
  tests/test_schema_core.py
@@ -0,0 +1,63 @@
1
+ from types import SimpleNamespace
2
+ from unittest import TestCase
3
+ from unittest.mock import patch
4
+
5
+ import pandas as pd
6
+
7
+ from tablemaster.database import ManageTable
8
+
9
+
10
+ class _BeginContext:
11
+ def __enter__(self):
12
+ return SimpleNamespace()
13
+
14
+ def __exit__(self, exc_type, exc, tb):
15
+ return False
16
+
17
+
18
+ class _Engine:
19
+ def begin(self):
20
+ return _BeginContext()
21
+
22
+
23
+ def _postgresql_cfg():
24
+ return SimpleNamespace(
25
+ name='pg',
26
+ user='u',
27
+ password='p',
28
+ host='127.0.0.1',
29
+ database='d',
30
+ db_type='postgresql',
31
+ )
32
+
33
+
34
+ class PostgreSQLUpsertTests(TestCase):
35
+ def test_postgresql_upsert_uses_execute_values(self):
36
+ table = ManageTable('orders', _postgresql_cfg())
37
+ df = pd.DataFrame({'id': [1, 2], 'name': ['alpha', None]})
38
+
39
+ with patch('tablemaster.database._resolve_engine', return_value=_Engine()):
40
+ with patch('tablemaster.database._execute_postgresql_values') as execute_values:
41
+ table.upsert_data(df, key='id')
42
+
43
+ execute_values.assert_called_once()
44
+ _, sql, rows = execute_values.call_args.args
45
+ self.assertIn('INSERT INTO orders ("id", "name")', sql)
46
+ self.assertIn('VALUES %s', sql)
47
+ self.assertIn('ON CONFLICT ("id") DO UPDATE SET "name"=EXCLUDED."name"', sql)
48
+ self.assertEqual([(1, 'alpha'), (2, None)], rows)
49
+ self.assertEqual(10000, execute_values.call_args.kwargs['page_size'])
50
+
51
+ def test_postgresql_ignore_uses_on_conflict_do_nothing(self):
52
+ table = ManageTable('public.orders', _postgresql_cfg())
53
+ df = pd.DataFrame({'id': [1], 'name': ['alpha']})
54
+
55
+ with patch('tablemaster.database._resolve_engine', return_value=_Engine()):
56
+ with patch('tablemaster.database._execute_postgresql_values') as execute_values:
57
+ table.upsert_data(df, ignore=True)
58
+
59
+ execute_values.assert_called_once()
60
+ _, sql, rows = execute_values.call_args.args
61
+ self.assertIn('INSERT INTO public.orders ("id", "name")', sql)
62
+ self.assertIn('ON CONFLICT DO NOTHING', sql)
63
+ self.assertEqual([(1, 'alpha')], rows)
File without changes
File without changes
File without changes