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.
- {tablemaster-2.1.9 → tablemaster-2.1.10}/PKG-INFO +1 -1
- {tablemaster-2.1.9 → tablemaster-2.1.10}/pyproject.toml +1 -1
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/database.py +44 -8
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/PKG-INFO +1 -1
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/SOURCES.txt +1 -0
- tablemaster-2.1.10/tests/test_postgresql_upsert.py +63 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/LICENSE +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/README.md +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/setup.cfg +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/__init__.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/__main__.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/cli.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/config.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/feishu.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/gspread.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/local.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/__init__.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/apply.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/__init__.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/base.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/mysql.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/postgresql.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/dialects/tidb.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/diff.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/init.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/introspect.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/loader.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/models.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/plan.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/schema/pull.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/sync.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster/utils.py +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/dependency_links.txt +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/entry_points.txt +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/requires.txt +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tablemaster.egg-info/top_level.txt +0 -0
- {tablemaster-2.1.9 → tablemaster-2.1.10}/tests/test_error_visibility.py +0 -0
- {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.
|
|
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.
|
|
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 {
|
|
395
|
-
VALUES
|
|
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 {
|
|
401
|
-
VALUES
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|