sqlalchemy-upsert-kit 0.1.1__tar.gz → 0.2.1__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.
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/PKG-INFO +67 -40
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/README.rst +21 -1
- sqlalchemy_upsert_kit-0.2.1/pyproject.toml +132 -0
- sqlalchemy_upsert_kit-0.2.1/setup.cfg +4 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/__init__.py +1 -0
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/api.py +1 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/__init__.py +1 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/executor.py +207 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/schema.py +78 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/validation.py +100 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/paths.py +60 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/__init__.py +15 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/executor.py +38 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_ignore.py +64 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_merge.py +69 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_replace.py +66 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/__init__.py +16 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/api.py +5 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/executor.py +52 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_ignore.py +64 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_merge.py +69 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_replace.py +66 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/PKG-INFO +146 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/SOURCES.txt +33 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/dependency_links.txt +1 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/requires.txt +30 -0
- sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/top_level.txt +1 -0
- sqlalchemy_upsert_kit-0.2.1/tests/test_api.py +25 -0
- sqlalchemy_upsert_kit-0.2.1/tests/test_exc.py +21 -0
- sqlalchemy_upsert_kit-0.1.1/pyproject.toml +0 -151
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/__init__.py +0 -9
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/_version.py +0 -15
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/paths.py +0 -33
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/__init__.py +0 -34
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/executor.py +0 -535
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_ignore.py +0 -156
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_merge.py +0 -254
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_replace.py +0 -177
- sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/utils.py +0 -92
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/AUTHORS.rst +0 -0
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/LICENSE.txt +0 -0
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/exc.py +0 -0
- {sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite → sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres}/api.py +0 -0
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/vendor/__init__.py +0 -0
- {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/vendor/pytest_cov_helper.py +0 -0
|
@@ -1,49 +1,57 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy_upsert_kit
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Fast SQLAlchemy bulk upsert (insert or ignore, replace, or merge) for SQLite and PostgreSQL, using temporary table staging - typically 5-20x faster than row-by-row upserts.
|
|
5
|
+
Author-email: Sanhe Hu <husanhe@gmail.com>
|
|
6
|
+
Maintainer-email: Sanhe Hu <husanhe@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project
|
|
9
|
+
Project-URL: Documentation, https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/
|
|
10
|
+
Project-URL: Repository, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project
|
|
11
|
+
Project-URL: Issues, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/blob/main/release-history.rst
|
|
13
|
+
Project-URL: Download, https://pypi.org/pypi/sqlalchemy-upsert-kit#files
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Operating System :: Unix
|
|
12
20
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
23
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
-
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Requires-Python: <4.0,>=3.11
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
License-File: LICENSE.txt
|
|
28
|
+
License-File: AUTHORS.rst
|
|
29
|
+
Requires-Dist: sqlalchemy<3.0.0,>=2.0.28
|
|
30
|
+
Provides-Extra: postgres
|
|
31
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.8; extra == "postgres"
|
|
19
32
|
Provides-Extra: dev
|
|
20
|
-
|
|
33
|
+
Requires-Dist: rich<15.0.0,>=14.3.4; extra == "dev"
|
|
21
34
|
Provides-Extra: test
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist:
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist:
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist:
|
|
33
|
-
Requires-Dist:
|
|
34
|
-
Requires-Dist:
|
|
35
|
-
Requires-Dist:
|
|
36
|
-
Requires-Dist:
|
|
37
|
-
Requires-Dist:
|
|
38
|
-
|
|
39
|
-
Requires-Dist:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Project-URL: Download, https://pypi.org/pypi/sqlalchemy-upsert-kit#files
|
|
43
|
-
Project-URL: Homepage, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project
|
|
44
|
-
Project-URL: Issues, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/issues
|
|
45
|
-
Project-URL: Repository, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project
|
|
46
|
-
Description-Content-Type: text/x-rst
|
|
35
|
+
Requires-Dist: pytest<10.0.0,>=9.1.1; extra == "test"
|
|
36
|
+
Requires-Dist: pytest-cov<8.0.0,>=7.1.0; extra == "test"
|
|
37
|
+
Requires-Dist: prettytable<4.0.0,>=3.0.0; extra == "test"
|
|
38
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.8; extra == "test"
|
|
39
|
+
Provides-Extra: doc
|
|
40
|
+
Requires-Dist: Sphinx<9.0.0,>=8.2.3; extra == "doc"
|
|
41
|
+
Requires-Dist: sphinx-copybutton<1.0.0,>=0.5.2; extra == "doc"
|
|
42
|
+
Requires-Dist: sphinx-design<1.0.0,>=0.7.0; extra == "doc"
|
|
43
|
+
Requires-Dist: sphinx-jinja<3.0.0,>=2.0.2; extra == "doc"
|
|
44
|
+
Requires-Dist: furo==2025.12.19; extra == "doc"
|
|
45
|
+
Requires-Dist: pygments<3.0.0,>=2.18.0; extra == "doc"
|
|
46
|
+
Requires-Dist: ipython<8.19.0,>=8.18.1; extra == "doc"
|
|
47
|
+
Requires-Dist: ipykernel<8.0.0,>=7.3.0; extra == "doc"
|
|
48
|
+
Requires-Dist: nbsphinx<1.0.0,>=0.9.8; extra == "doc"
|
|
49
|
+
Requires-Dist: rstobj<3.0.0,>=2.0.0; extra == "doc"
|
|
50
|
+
Requires-Dist: docfly<4.0.0,>=3.0.3; extra == "doc"
|
|
51
|
+
Provides-Extra: mise
|
|
52
|
+
Requires-Dist: PyGithub<3.0.0,>=2.8.0; extra == "mise"
|
|
53
|
+
Requires-Dist: httpx2<3.0.0,>=2.13.1; extra == "mise"
|
|
54
|
+
Dynamic: license-file
|
|
47
55
|
|
|
48
56
|
|
|
49
57
|
.. image:: https://readthedocs.org/projects/sqlalchemy-upsert-kit/badge/?version=latest
|
|
@@ -97,7 +105,13 @@ Welcome to ``sqlalchemy_upsert_kit`` Documentation
|
|
|
97
105
|
.. image:: https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/_static/sqlalchemy_upsert_kit-logo.png
|
|
98
106
|
:target: https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/
|
|
99
107
|
|
|
100
|
-
``sqlalchemy_upsert_kit`` provides
|
|
108
|
+
``sqlalchemy_upsert_kit`` provides fast bulk upsert operations for SQLAlchemy applications, on SQLite and PostgreSQL. It offers three strategies:
|
|
109
|
+
|
|
110
|
+
- ``insert_or_ignore``: insert new records, and leave existing rows alone.
|
|
111
|
+
- ``insert_or_replace``: insert new records, and overwrite existing rows with the records.
|
|
112
|
+
- ``insert_or_merge``: insert new records, and update only chosen columns of existing rows.
|
|
113
|
+
|
|
114
|
+
Each call loads the records into a temporary staging table and writes them with one native ``INSERT ... ON CONFLICT`` statement, typically 5-20x faster than row-by-row upserts on large batches, which suits ETL, data synchronization, and other bulk loads. Every call is atomic, and can run in its own transaction or inside yours.
|
|
101
115
|
|
|
102
116
|
|
|
103
117
|
.. _install:
|
|
@@ -117,3 +131,16 @@ To upgrade to latest version:
|
|
|
117
131
|
|
|
118
132
|
$ pip install --upgrade sqlalchemy-upsert-kit
|
|
119
133
|
|
|
134
|
+
|
|
135
|
+
.. _use-with-ai-coding-agents:
|
|
136
|
+
|
|
137
|
+
Use with AI Coding Agents
|
|
138
|
+
------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
This repo ships an `Agent Skill <https://code.claude.com/docs/en/skills>`_ that documents the public API of ``sqlalchemy_upsert_kit``, so an AI coding agent such as Claude Code can use the library without reading its source. It lives in `.claude/skills/pypi-sqlalchemy_upsert_kit <https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/tree/main/.claude/skills/pypi-sqlalchemy_upsert_kit>`_. To use it in your project, copy it into your project's ``.claude/skills/`` folder. Run this from your project's root:
|
|
141
|
+
|
|
142
|
+
.. code-block:: console
|
|
143
|
+
|
|
144
|
+
$ git clone --depth 1 https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project.git /tmp/sqlalchemy_upsert_kit-project
|
|
145
|
+
$ mkdir -p .claude/skills
|
|
146
|
+
$ cp -R /tmp/sqlalchemy_upsert_kit-project/.claude/skills/pypi-sqlalchemy_upsert_kit .claude/skills/
|
|
@@ -50,7 +50,13 @@ Welcome to ``sqlalchemy_upsert_kit`` Documentation
|
|
|
50
50
|
.. image:: https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/_static/sqlalchemy_upsert_kit-logo.png
|
|
51
51
|
:target: https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/
|
|
52
52
|
|
|
53
|
-
``sqlalchemy_upsert_kit`` provides
|
|
53
|
+
``sqlalchemy_upsert_kit`` provides fast bulk upsert operations for SQLAlchemy applications, on SQLite and PostgreSQL. It offers three strategies:
|
|
54
|
+
|
|
55
|
+
- ``insert_or_ignore``: insert new records, and leave existing rows alone.
|
|
56
|
+
- ``insert_or_replace``: insert new records, and overwrite existing rows with the records.
|
|
57
|
+
- ``insert_or_merge``: insert new records, and update only chosen columns of existing rows.
|
|
58
|
+
|
|
59
|
+
Each call loads the records into a temporary staging table and writes them with one native ``INSERT ... ON CONFLICT`` statement, typically 5-20x faster than row-by-row upserts on large batches, which suits ETL, data synchronization, and other bulk loads. Every call is atomic, and can run in its own transaction or inside yours.
|
|
54
60
|
|
|
55
61
|
|
|
56
62
|
.. _install:
|
|
@@ -69,3 +75,17 @@ To upgrade to latest version:
|
|
|
69
75
|
.. code-block:: console
|
|
70
76
|
|
|
71
77
|
$ pip install --upgrade sqlalchemy-upsert-kit
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
.. _use-with-ai-coding-agents:
|
|
81
|
+
|
|
82
|
+
Use with AI Coding Agents
|
|
83
|
+
------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
This repo ships an `Agent Skill <https://code.claude.com/docs/en/skills>`_ that documents the public API of ``sqlalchemy_upsert_kit``, so an AI coding agent such as Claude Code can use the library without reading its source. It lives in `.claude/skills/pypi-sqlalchemy_upsert_kit <https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/tree/main/.claude/skills/pypi-sqlalchemy_upsert_kit>`_. To use it in your project, copy it into your project's ``.claude/skills/`` folder. Run this from your project's root:
|
|
86
|
+
|
|
87
|
+
.. code-block:: console
|
|
88
|
+
|
|
89
|
+
$ git clone --depth 1 https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project.git /tmp/sqlalchemy_upsert_kit-project
|
|
90
|
+
$ mkdir -p .claude/skills
|
|
91
|
+
$ cp -R /tmp/sqlalchemy_upsert_kit-project/.claude/skills/pypi-sqlalchemy_upsert_kit .claude/skills/
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# [project] table: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
|
|
2
|
+
[project]
|
|
3
|
+
name = "sqlalchemy_upsert_kit"
|
|
4
|
+
# Bump before each release (https://semver.org/), then ``mise run inst`` to update ``uv.lock``.
|
|
5
|
+
version = "0.2.1"
|
|
6
|
+
description = "Fast SQLAlchemy bulk upsert (insert or ignore, replace, or merge) for SQLite and PostgreSQL, using temporary table staging - typically 5-20x faster than row-by-row upserts."
|
|
7
|
+
# An SPDX license expression (PEP 639); keep ``LICENSE.txt`` in sync.
|
|
8
|
+
# Read: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-and-license-files
|
|
9
|
+
# Valid identifiers: https://spdx.org/licenses/
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.txt", "AUTHORS.rst"]
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Sanhe Hu", email = "husanhe@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Sanhe Hu", email = "husanhe@gmail.com" },
|
|
17
|
+
]
|
|
18
|
+
keywords = []
|
|
19
|
+
readme = "README.rst"
|
|
20
|
+
requires-python = ">=3.11,<4.0"
|
|
21
|
+
# Read: https://pypi.org/classifiers/
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Natural Language :: English",
|
|
26
|
+
"Operating System :: Microsoft :: Windows",
|
|
27
|
+
"Operating System :: MacOS",
|
|
28
|
+
"Operating System :: Unix",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# --- Dependencies ---
|
|
37
|
+
# Edit these lists, then ``mise run inst`` to update ``uv.lock`` and ``.venv``.
|
|
38
|
+
dependencies = [
|
|
39
|
+
"sqlalchemy>=2.0.28,<3.0.0", # database toolkit and ORM; 2.0.28 adds preserve_rowcount
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# --- Optional dependencies (extras) ---
|
|
43
|
+
# Read: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-optional-dependencies
|
|
44
|
+
# Each one must support every Python version in ``requires-python``.
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
|
|
47
|
+
# PostgreSQL driver for ``api.postgres``
|
|
48
|
+
postgres = [
|
|
49
|
+
"psycopg[binary]>=3.3.8,<4.0.0", # PostgreSQL driver
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# local development
|
|
53
|
+
dev = [
|
|
54
|
+
"rich>=14.3.4,<15.0.0",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# tests and coverage
|
|
58
|
+
test = [
|
|
59
|
+
"pytest>=9.1.1,<10.0.0",
|
|
60
|
+
"pytest-cov>=7.1.0,<8.0.0",
|
|
61
|
+
"prettytable>=3.0.0,<4.0.0", # pretty print tables in test output
|
|
62
|
+
"psycopg[binary]>=3.3.8,<4.0.0", # PostgreSQL driver for the postgres tests
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# docs build
|
|
66
|
+
doc = [
|
|
67
|
+
"Sphinx>=8.2.3,<9.0.0", # held below 9 until its autodoc rewrite is verified
|
|
68
|
+
"sphinx-copybutton>=0.5.2,<1.0.0", # copy button on code blocks
|
|
69
|
+
"sphinx-design>=0.7.0,<1.0.0", # cards, tabs, grids
|
|
70
|
+
"sphinx-jinja>=2.0.2,<3.0.0", # jinja in reST
|
|
71
|
+
"furo==2025.12.19", # theme
|
|
72
|
+
"pygments>=2.18.0,<3.0.0", # syntax highlighting
|
|
73
|
+
"ipython>=8.18.1,<8.19.0", # notebook execution
|
|
74
|
+
"ipykernel>=7.3.0,<8.0.0", # notebook kernel
|
|
75
|
+
"nbsphinx>=0.9.8,<1.0.0", # notebooks as doc pages
|
|
76
|
+
"rstobj>=2.0.0,<3.0.0", # reST from Python
|
|
77
|
+
"docfly>=3.0.3,<4.0.0", # toctree and API reference
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# mise tasks
|
|
81
|
+
mise = [
|
|
82
|
+
"PyGithub>=2.8.0,<3.0.0", # GitHub API client
|
|
83
|
+
"httpx2>=2.13.1,<3.0.0", # Codecov and Read the Docs APIs
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[project.urls]
|
|
87
|
+
Homepage = "https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project"
|
|
88
|
+
Documentation = "https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/"
|
|
89
|
+
Repository = "https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project"
|
|
90
|
+
Issues = "https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/issues"
|
|
91
|
+
Changelog = "https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/blob/main/release-history.rst"
|
|
92
|
+
Download = "https://pypi.org/pypi/sqlalchemy-upsert-kit#files"
|
|
93
|
+
|
|
94
|
+
# Read: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#creating-executable-scripts
|
|
95
|
+
[project.scripts]
|
|
96
|
+
|
|
97
|
+
[tool.setuptools.packages.find]
|
|
98
|
+
where = ["."]
|
|
99
|
+
include = [
|
|
100
|
+
"sqlalchemy_upsert_kit*",
|
|
101
|
+
]
|
|
102
|
+
exclude = [
|
|
103
|
+
"sqlalchemy_upsert_kit.tests",
|
|
104
|
+
"sqlalchemy_upsert_kit.tests.*",
|
|
105
|
+
"sqlalchemy_upsert_kit.docs",
|
|
106
|
+
"sqlalchemy_upsert_kit.docs.*",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
# --- ruff: ``mise run format`` / ``mise run lint`` ---
|
|
110
|
+
[tool.ruff]
|
|
111
|
+
extend-exclude = [
|
|
112
|
+
"sqlalchemy_upsert_kit/vendor",
|
|
113
|
+
".claude/skills/*/assets", # templates, not valid Python
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.ruff.lint.per-file-ignores]
|
|
117
|
+
# re-export modules
|
|
118
|
+
"**/__init__.py" = ["F401"]
|
|
119
|
+
"**/api.py" = ["F401"]
|
|
120
|
+
|
|
121
|
+
# --- pytest 9+ native table ---
|
|
122
|
+
# Read: https://docs.pytest.org/en/stable/reference/customize.html#pyproject-toml
|
|
123
|
+
[tool.pytest]
|
|
124
|
+
testpaths = ["tests"]
|
|
125
|
+
# strict = true # only with a pinned pytest
|
|
126
|
+
# filterwarnings = ["error"]
|
|
127
|
+
# markers = ["slow: tests that take more than a few seconds"]
|
|
128
|
+
|
|
129
|
+
# --- Build (``mise run build``); setuptools 77+ for PEP 639 ``license`` ---
|
|
130
|
+
[build-system]
|
|
131
|
+
requires = ["setuptools>=77.0"]
|
|
132
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
The dialect-agnostic upsert workflow, shared by every backend.
|
|
5
|
+
|
|
6
|
+
:class:`UpsertExecutor` runs one upsert as these steps, all inside a SAVEPOINT
|
|
7
|
+
on one connection::
|
|
8
|
+
|
|
9
|
+
begin_transaction() # backend hook: make sure a real transaction is open
|
|
10
|
+
SAVEPOINT
|
|
11
|
+
create the staging table # TEMPORARY, private to the connection
|
|
12
|
+
load the records into it
|
|
13
|
+
count the conflicting rows
|
|
14
|
+
INSERT ... SELECT ... ON CONFLICT # backend hook: build_upsert()
|
|
15
|
+
drop the staging table
|
|
16
|
+
RELEASE SAVEPOINT # on error: ROLLBACK TO SAVEPOINT, then re-raise
|
|
17
|
+
|
|
18
|
+
Rolling back to the savepoint undoes everything the upsert did, the staging
|
|
19
|
+
table included, and leaves the caller's transaction usable. In auto-managed
|
|
20
|
+
mode the executor opens and commits its own connection and transaction; in
|
|
21
|
+
user-managed mode it runs on the caller's connection and never commits.
|
|
22
|
+
|
|
23
|
+
A backend subclasses :class:`UpsertExecutor` and implements :meth:`build_upsert`,
|
|
24
|
+
plus :meth:`begin_transaction` if its driver delays ``BEGIN``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import typing as T
|
|
28
|
+
import abc
|
|
29
|
+
import dataclasses
|
|
30
|
+
|
|
31
|
+
import sqlalchemy as sa
|
|
32
|
+
|
|
33
|
+
from ..exc import UpsertTestError
|
|
34
|
+
|
|
35
|
+
from .schema import get_pk_column
|
|
36
|
+
from .schema import make_staging_name
|
|
37
|
+
from .schema import make_staging_table
|
|
38
|
+
from .validation import validate_records
|
|
39
|
+
from .validation import resolve_update_keys
|
|
40
|
+
|
|
41
|
+
#: The steps where the private ``_fail_at`` test hook can inject a failure.
|
|
42
|
+
FAIL_STEPS = ("create_staging", "load_staging", "upsert", "drop_staging")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass
|
|
46
|
+
class UpsertExecutor(abc.ABC):
|
|
47
|
+
"""
|
|
48
|
+
One bulk upsert of ``values`` into ``table`` with one strategy.
|
|
49
|
+
|
|
50
|
+
All inputs are validated when the executor is created, before any SQL runs.
|
|
51
|
+
:meth:`run` returns ``(conflicting_rows, inserted_rows)``: the number of
|
|
52
|
+
records whose primary key already existed (ignored, replaced or updated,
|
|
53
|
+
depending on the strategy), and the number of new rows.
|
|
54
|
+
|
|
55
|
+
:param engine: Engine used in auto-managed mode
|
|
56
|
+
:param table: Target table, with exactly one primary key column
|
|
57
|
+
:param values: Records keyed by column key, all with the same keys
|
|
58
|
+
:param strategy: ``"ignore"``, ``"replace"`` or ``"merge"``
|
|
59
|
+
:param columns: Column keys that ``"merge"`` updates; unused otherwise
|
|
60
|
+
:param metadata: Metadata that holds the staging table definition while
|
|
61
|
+
the upsert runs. A new one when None.
|
|
62
|
+
:param temp_table_name: Staging table name. Generated when None.
|
|
63
|
+
:param conn: Caller's connection, for user-managed mode, with ``trans``
|
|
64
|
+
:param trans: Caller's transaction, for user-managed mode, with ``conn``
|
|
65
|
+
:param fail_at: **Testing only**: raise :class:`UpsertTestError` just
|
|
66
|
+
before this step, one of :data:`FAIL_STEPS`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# fmt: off
|
|
70
|
+
engine: sa.Engine
|
|
71
|
+
table: sa.Table
|
|
72
|
+
values: list[dict[str, T.Any]]
|
|
73
|
+
strategy: str
|
|
74
|
+
columns: list[str] | None = None
|
|
75
|
+
metadata: sa.MetaData | None = None
|
|
76
|
+
temp_table_name: str | None = None
|
|
77
|
+
conn: sa.Connection | None = None
|
|
78
|
+
trans: sa.Transaction | None = None
|
|
79
|
+
fail_at: str | None = None
|
|
80
|
+
# set by __post_init__
|
|
81
|
+
pk_column: sa.Column = dataclasses.field(init=False)
|
|
82
|
+
update_keys: list[str] = dataclasses.field(init=False)
|
|
83
|
+
# fmt: on
|
|
84
|
+
|
|
85
|
+
def __post_init__(self):
|
|
86
|
+
if (self.conn is None) != (self.trans is None):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Give both conn and trans (user-managed mode) or neither "
|
|
89
|
+
"(auto-managed mode)"
|
|
90
|
+
)
|
|
91
|
+
if self.fail_at is not None and self.fail_at not in FAIL_STEPS:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"fail_at must be one of {FAIL_STEPS}, got {self.fail_at!r}"
|
|
94
|
+
)
|
|
95
|
+
self.pk_column = get_pk_column(self.table)
|
|
96
|
+
if self.values:
|
|
97
|
+
validate_records(self.table, self.values)
|
|
98
|
+
record_keys = self.values[0].keys() if self.values else []
|
|
99
|
+
self.update_keys = resolve_update_keys(
|
|
100
|
+
table=self.table,
|
|
101
|
+
strategy=self.strategy,
|
|
102
|
+
record_keys=record_keys,
|
|
103
|
+
columns=self.columns,
|
|
104
|
+
)
|
|
105
|
+
if self.metadata is None:
|
|
106
|
+
self.metadata = sa.MetaData()
|
|
107
|
+
if self.temp_table_name is None:
|
|
108
|
+
self.temp_table_name = make_staging_name(
|
|
109
|
+
table=self.table,
|
|
110
|
+
max_length=self.engine.dialect.max_identifier_length,
|
|
111
|
+
)
|
|
112
|
+
if self.temp_table_name == self.table.name:
|
|
113
|
+
raise ValueError("temp_table_name must differ from the target table name")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def user_managed(self) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
True when the upsert runs in the caller's transaction.
|
|
119
|
+
"""
|
|
120
|
+
return self.conn is not None
|
|
121
|
+
|
|
122
|
+
def begin_transaction(self, conn: sa.Connection) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Make sure ``conn`` is inside a real database transaction before the
|
|
125
|
+
SAVEPOINT. The default does nothing: most drivers begin one on
|
|
126
|
+
``Connection.begin()``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
@abc.abstractmethod
|
|
130
|
+
def build_upsert(self, staging: sa.Table) -> sa.Insert:
|
|
131
|
+
"""
|
|
132
|
+
Build the one statement that copies every staged row into the target
|
|
133
|
+
table, overwriting :attr:`update_keys` on conflicting rows (or nothing,
|
|
134
|
+
when it is empty).
|
|
135
|
+
"""
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
def maybe_fail(self, step: str) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Raise the test failure if ``fail_at`` names this step.
|
|
141
|
+
"""
|
|
142
|
+
if self.fail_at == step:
|
|
143
|
+
raise UpsertTestError(f"injected failure before step {step!r}")
|
|
144
|
+
|
|
145
|
+
def count_conflicts(self, conn: sa.Connection, staging: sa.Table) -> int:
|
|
146
|
+
"""
|
|
147
|
+
Count the staged rows whose primary key already exists in the target.
|
|
148
|
+
"""
|
|
149
|
+
pk_key = self.pk_column.key
|
|
150
|
+
stmt = (
|
|
151
|
+
sa.select(sa.func.count())
|
|
152
|
+
.select_from(staging)
|
|
153
|
+
.join(self.table, staging.c[pk_key] == self.table.c[pk_key])
|
|
154
|
+
)
|
|
155
|
+
return conn.execute(stmt).scalar_one()
|
|
156
|
+
|
|
157
|
+
def run_on(self, conn: sa.Connection) -> tuple[int, int]:
|
|
158
|
+
"""
|
|
159
|
+
Run the upsert on ``conn``, inside a SAVEPOINT of its open transaction.
|
|
160
|
+
"""
|
|
161
|
+
self.begin_transaction(conn)
|
|
162
|
+
staging = make_staging_table(
|
|
163
|
+
table=self.table,
|
|
164
|
+
metadata=self.metadata,
|
|
165
|
+
name=self.temp_table_name,
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
with conn.begin_nested():
|
|
169
|
+
self.maybe_fail("create_staging")
|
|
170
|
+
staging.create(conn)
|
|
171
|
+
self.maybe_fail("load_staging")
|
|
172
|
+
conn.execute(staging.insert(), self.values)
|
|
173
|
+
if self.update_keys:
|
|
174
|
+
# DO UPDATE's rowcount counts inserted and updated rows
|
|
175
|
+
# together, so count the conflicts first. If another
|
|
176
|
+
# transaction writes the same keys in between, the split
|
|
177
|
+
# can be off; the sum is always len(values).
|
|
178
|
+
conflicts = self.count_conflicts(conn, staging)
|
|
179
|
+
self.maybe_fail("upsert")
|
|
180
|
+
stmt = self.build_upsert(staging)
|
|
181
|
+
# SQLAlchemy keeps the rowcount of an INSERT only when asked to.
|
|
182
|
+
res = conn.execute(stmt.execution_options(preserve_rowcount=True))
|
|
183
|
+
if self.update_keys:
|
|
184
|
+
inserted = res.rowcount - conflicts
|
|
185
|
+
else:
|
|
186
|
+
# DO NOTHING's rowcount is exactly the inserted rows.
|
|
187
|
+
inserted = res.rowcount
|
|
188
|
+
conflicts = len(self.values) - inserted
|
|
189
|
+
self.maybe_fail("drop_staging")
|
|
190
|
+
staging.drop(conn)
|
|
191
|
+
finally:
|
|
192
|
+
self.metadata.remove(staging)
|
|
193
|
+
return conflicts, inserted
|
|
194
|
+
|
|
195
|
+
def run(self) -> tuple[int, int]:
|
|
196
|
+
"""
|
|
197
|
+
Run the upsert and return ``(conflicting_rows, inserted_rows)``.
|
|
198
|
+
|
|
199
|
+
Empty ``values`` is a no-op that returns ``(0, 0)``.
|
|
200
|
+
"""
|
|
201
|
+
if not self.values:
|
|
202
|
+
return 0, 0
|
|
203
|
+
if self.user_managed:
|
|
204
|
+
return self.run_on(self.conn)
|
|
205
|
+
with self.engine.connect() as conn:
|
|
206
|
+
with conn.begin():
|
|
207
|
+
return self.run_on(conn)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Table introspection and the staging table definition, shared by every backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_pk_column(table: sa.Table) -> sa.Column:
|
|
13
|
+
"""
|
|
14
|
+
Return the single primary key column of ``table``.
|
|
15
|
+
|
|
16
|
+
:raises ValueError: If the table has zero or several primary key columns.
|
|
17
|
+
"""
|
|
18
|
+
pks = list(table.primary_key.columns)
|
|
19
|
+
if len(pks) != 1:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Table {table.name!r} must have exactly one primary key column, "
|
|
22
|
+
f"found {[col.name for col in pks]}"
|
|
23
|
+
)
|
|
24
|
+
return pks[0]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_writable_columns(table: sa.Table) -> list[sa.Column]:
|
|
28
|
+
"""
|
|
29
|
+
Return the columns an upsert may write: every column except generated
|
|
30
|
+
(``Computed``) ones.
|
|
31
|
+
"""
|
|
32
|
+
return [col for col in table.columns if col.computed is None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_staging_name(
|
|
36
|
+
table: sa.Table,
|
|
37
|
+
max_length: int,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Generate a staging table name: ``temp_<8 hex>_<table name>``, cut to
|
|
41
|
+
``max_length`` (the dialect's identifier limit, 63 on PostgreSQL).
|
|
42
|
+
|
|
43
|
+
The random part keeps names unique across retries and concurrent calls.
|
|
44
|
+
"""
|
|
45
|
+
return f"temp_{uuid.uuid4().hex[:8]}_{table.name}"[:max_length]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_staging_table(
|
|
49
|
+
table: sa.Table,
|
|
50
|
+
metadata: sa.MetaData,
|
|
51
|
+
name: str,
|
|
52
|
+
) -> sa.Table:
|
|
53
|
+
"""
|
|
54
|
+
Define the ``TEMPORARY`` staging table for ``table``.
|
|
55
|
+
|
|
56
|
+
It copies each writable column's name, key, type and defaults, and marks the
|
|
57
|
+
primary key, but nothing else: no indexes, unique constraints, foreign keys,
|
|
58
|
+
sequences or identities, which could collide with the target's names or
|
|
59
|
+
reference tables a temporary table may not reference. A temporary table is
|
|
60
|
+
private to its connection, so concurrent upserts never see each other's.
|
|
61
|
+
|
|
62
|
+
:param table: The target table
|
|
63
|
+
:param metadata: Metadata that holds the staging table definition
|
|
64
|
+
:param name: Name of the staging table
|
|
65
|
+
"""
|
|
66
|
+
columns = list()
|
|
67
|
+
for col in get_writable_columns(table):
|
|
68
|
+
kwargs = dict(
|
|
69
|
+
key=col.key,
|
|
70
|
+
primary_key=col.primary_key,
|
|
71
|
+
autoincrement=False, # the key always comes from the input records
|
|
72
|
+
)
|
|
73
|
+
if isinstance(col.server_default, sa.DefaultClause):
|
|
74
|
+
kwargs["server_default"] = sa.DefaultClause(col.server_default.arg)
|
|
75
|
+
if col.default is not None and not col.default.is_sequence:
|
|
76
|
+
kwargs["default"] = col.default.arg
|
|
77
|
+
columns.append(sa.Column(col.name, col.type, **kwargs))
|
|
78
|
+
return sa.Table(name, metadata, *columns, prefixes=["TEMPORARY"])
|