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.
Files changed (45) hide show
  1. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/PKG-INFO +67 -40
  2. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/README.rst +21 -1
  3. sqlalchemy_upsert_kit-0.2.1/pyproject.toml +132 -0
  4. sqlalchemy_upsert_kit-0.2.1/setup.cfg +4 -0
  5. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/__init__.py +1 -0
  6. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/api.py +1 -0
  7. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/__init__.py +1 -0
  8. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/executor.py +207 -0
  9. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/schema.py +78 -0
  10. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/core/validation.py +100 -0
  11. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/paths.py +60 -0
  12. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/__init__.py +15 -0
  13. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/executor.py +38 -0
  14. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_ignore.py +64 -0
  15. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_merge.py +69 -0
  16. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres/insert_or_replace.py +66 -0
  17. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/__init__.py +16 -0
  18. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/api.py +5 -0
  19. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/executor.py +52 -0
  20. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_ignore.py +64 -0
  21. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_merge.py +69 -0
  22. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/sqlite/insert_or_replace.py +66 -0
  23. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/PKG-INFO +146 -0
  24. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/SOURCES.txt +33 -0
  25. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/dependency_links.txt +1 -0
  26. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/requires.txt +30 -0
  27. sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit.egg-info/top_level.txt +1 -0
  28. sqlalchemy_upsert_kit-0.2.1/tests/test_api.py +25 -0
  29. sqlalchemy_upsert_kit-0.2.1/tests/test_exc.py +21 -0
  30. sqlalchemy_upsert_kit-0.1.1/pyproject.toml +0 -151
  31. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/__init__.py +0 -9
  32. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/_version.py +0 -15
  33. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/paths.py +0 -33
  34. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/__init__.py +0 -34
  35. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/executor.py +0 -535
  36. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_ignore.py +0 -156
  37. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_merge.py +0 -254
  38. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite/insert_or_replace.py +0 -177
  39. sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/utils.py +0 -92
  40. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/AUTHORS.rst +0 -0
  41. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/LICENSE.txt +0 -0
  42. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/exc.py +0 -0
  43. {sqlalchemy_upsert_kit-0.1.1/sqlalchemy_upsert_kit/sqlite → sqlalchemy_upsert_kit-0.2.1/sqlalchemy_upsert_kit/postgres}/api.py +0 -0
  44. {sqlalchemy_upsert_kit-0.1.1 → sqlalchemy_upsert_kit-0.2.1}/sqlalchemy_upsert_kit/vendor/__init__.py +0 -0
  45. {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.3
1
+ Metadata-Version: 2.4
2
2
  Name: sqlalchemy_upsert_kit
3
- Version: 0.1.1
4
- Summary: High-performance SQLAlchemy bulk upsert operations using temporary table staging - 20x faster than row-by-row approaches.
5
- License: MIT
6
- Author: Sanhe Hu
7
- Author-email: husanhe@email.com
8
- Maintainer: Sanhe Hu
9
- Maintainer-email: husanhe@email.com
10
- Requires-Python: >=3.9,<4.0
11
- Classifier: License :: OSI Approved :: MIT License
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
- Provides-Extra: auto
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
- Provides-Extra: doc
33
+ Requires-Dist: rich<15.0.0,>=14.3.4; extra == "dev"
21
34
  Provides-Extra: test
22
- Requires-Dist: Sphinx (>=7.4.7,<8.0.0) ; extra == "doc"
23
- Requires-Dist: build (>=1.2.1,<2.0.0) ; extra == "dev"
24
- Requires-Dist: docfly (==3.0.0) ; extra == "doc"
25
- Requires-Dist: furo (==2024.8.6) ; extra == "doc"
26
- Requires-Dist: ipython (>=8.18.1,<8.19.0) ; extra == "doc"
27
- Requires-Dist: nbsphinx (>=0.8.12,<1.0.0) ; extra == "doc"
28
- Requires-Dist: prettytable (>=3.0.0,<4.0.0) ; extra == "test"
29
- Requires-Dist: pygments (>=2.18.0,<3.0.0) ; extra == "doc"
30
- Requires-Dist: pytest (>=8.2.2,<9.0.0) ; extra == "test"
31
- Requires-Dist: pytest-cov (>=6.0.0,<7.0.0) ; extra == "test"
32
- Requires-Dist: rich (>=13.8.1,<14.0.0) ; extra == "dev"
33
- Requires-Dist: rstobj (==1.2.1) ; extra == "doc"
34
- Requires-Dist: sphinx-copybutton (>=0.5.2,<1.0.0) ; extra == "doc"
35
- Requires-Dist: sphinx-design (>=0.6.1,<1.0.0) ; extra == "doc"
36
- Requires-Dist: sphinx-jinja (>=2.0.2,<3.0.0) ; extra == "doc"
37
- Requires-Dist: sqlalchemy (>=2.0.0,<3.0.0)
38
- Requires-Dist: twine (>=6.0.0,<7.0.0) ; extra == "dev"
39
- Requires-Dist: wheel (>=0.45.0,<1.0.0) ; extra == "dev"
40
- Project-URL: Changelog, https://github.com/MacHu-GWU/sqlalchemy_upsert_kit-project/blob/main/release-history.rst
41
- Project-URL: Documentation, https://sqlalchemy-upsert-kit.readthedocs.io/en/latest/
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 high-performance bulk upsert operations for SQLAlchemy applications using temporary table staging. It offers three optimized strategies: ``insert_or_ignore`` for conflict-free insertion, ``insert_or_replace`` for complete record replacement, and ``insert_or_merge`` for selective column updates while preserving existing data. Each strategy leverages efficient SQL JOIN operations and temporary tables to achieve ~20x performance improvements over traditional row-by-row upserts, making it ideal for ETL processes, data synchronization, and bulk data operations where speed and reliability are critical.
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 high-performance bulk upsert operations for SQLAlchemy applications using temporary table staging. It offers three optimized strategies: ``insert_or_ignore`` for conflict-free insertion, ``insert_or_replace`` for complete record replacement, and ``insert_or_merge`` for selective column updates while preserving existing data. Each strategy leverages efficient SQL JOIN operations and temporary tables to achieve ~20x performance improvements over traditional row-by-row upserts, making it ideal for ETL processes, data synchronization, and bulk data operations where speed and reliability are critical.
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
@@ -1,3 +1,4 @@
1
1
  # -*- coding: utf-8 -*-
2
2
 
3
3
  from .sqlite import api as sqlite
4
+ from .postgres import api as postgres
@@ -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"])