├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .meta.toml ├── CHANGES.rst ├── CONTRIBUTING.md ├── COPYRIGHT.txt ├── CREDITS.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── buildout.cfg ├── github_actions.cfg ├── github_actions20.cfg ├── mysql.cfg ├── oracle.cfg ├── postgres.cfg ├── postgres20.cfg ├── pysqlite.cfg ├── setup.cfg ├── setup.py ├── src └── zope │ ├── __init__.py │ └── sqlalchemy │ ├── README.rst │ ├── __init__.py │ ├── datamanager.py │ └── tests.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | # 4 | # EditorConfig Configuration file, for more details see: 5 | # http://EditorConfig.org 6 | # EditorConfig is a convention description, that could be interpreted 7 | # by multiple editors to enforce common coding conventions for specific 8 | # file types 9 | 10 | # top-most EditorConfig file: 11 | # Will ignore other EditorConfig files in Home directory or upper tree level. 12 | root = true 13 | 14 | 15 | [*] # For All Files 16 | # Unix-style newlines with a newline ending every file 17 | end_of_line = lf 18 | insert_final_newline = true 19 | trim_trailing_whitespace = true 20 | # Set default charset 21 | charset = utf-8 22 | # Indent style default 23 | indent_style = space 24 | # Max Line Length - a hard line wrap, should be disabled 25 | max_line_length = off 26 | 27 | [*.{py,cfg,ini}] 28 | # 4 space indentation 29 | indent_size = 4 30 | 31 | [*.{yml,zpt,pt,dtml,zcml}] 32 | # 2 space indentation 33 | indent_size = 2 34 | 35 | [{Makefile,.gitmodules}] 36 | # Tab indentation (no size specified, but view as 4 spaces) 37 | indent_style = tab 38 | indent_size = unset 39 | tab_width = unset 40 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | name: tests 4 | 5 | on: 6 | push: 7 | pull_request: 8 | schedule: 9 | - cron: '0 12 * * 0' # run once a week on Sunday 10 | # Allow to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | strategy: 16 | # We want to see all failures: 17 | fail-fast: false 18 | matrix: 19 | os: 20 | - ["ubuntu", "ubuntu-20.04"] 21 | config: 22 | # [Python version, tox env] 23 | - ["3.9", "lint"] 24 | - ["3.7", "py37"] 25 | - ["3.8", "py38"] 26 | - ["3.9", "py39"] 27 | - ["3.10", "py310"] 28 | - ["3.11", "py311"] 29 | - ["3.9", "coverage"] 30 | 31 | runs-on: ${{ matrix.os[1] }} 32 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name 33 | name: ${{ matrix.config[1] }} 34 | steps: 35 | - name: "Configure PostgreSQL" 36 | run: | 37 | sudo mkdir -p /usr/local/pgsql/data 38 | sudo chown postgres /usr/local/pgsql/data 39 | sudo su - postgres -c '/usr/lib/postgresql/14/bin/initdb -D /usr/local/pgsql/data' 40 | sudo su - postgres -c 'echo "max_prepared_transactions=10" >> /usr/local/pgsql/data/postgresql.conf' 41 | sudo su - postgres -c 'cat /usr/local/pgsql/data/postgresql.conf' 42 | sudo su - postgres -c '/usr/lib/postgresql/14/bin/pg_ctl -D /usr/local/pgsql/data -l logfile start' 43 | sudo su - postgres -c '/usr/lib/postgresql/14/bin/createdb zope_sqlalchemy_tests' 44 | sudo su - postgres -c '/usr/lib/postgresql/14/bin/psql -l' 45 | - uses: actions/checkout@v3 46 | - name: Set up Python 47 | uses: actions/setup-python@v4 48 | with: 49 | python-version: ${{ matrix.config[0] }} 50 | - name: Pip cache 51 | uses: actions/cache@v3 52 | with: 53 | path: ~/.cache/pip 54 | key: ${{ runner.os }}-pip-${{ matrix.config[0] }}-${{ hashFiles('setup.*', 'tox.ini') }} 55 | restore-keys: | 56 | ${{ runner.os }}-pip-${{ matrix.config[0] }}- 57 | ${{ runner.os }}-pip- 58 | - name: Install dependencies 59 | run: | 60 | python -m pip install --upgrade pip 61 | pip install tox 62 | - name: Test 63 | run: | 64 | tox -f ${{ matrix.config[1] }} 65 | - name: Coverage 66 | if: matrix.config[1] == 'coverage' 67 | run: | 68 | pip install coveralls 69 | coveralls --service=github 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | *.dll 4 | *.egg-info/ 5 | *.profraw 6 | *.pyc 7 | *.pyo 8 | *.so 9 | .coverage 10 | .coverage.* 11 | .eggs/ 12 | .installed.cfg 13 | .mr.developer.cfg 14 | .tox/ 15 | .vscode/ 16 | __pycache__/ 17 | bin/ 18 | build/ 19 | coverage.xml 20 | develop-eggs/ 21 | develop/ 22 | dist/ 23 | docs/_build 24 | eggs/ 25 | etc/ 26 | lib/ 27 | lib64 28 | log/ 29 | parts/ 30 | pyvenv.cfg 31 | testing.log 32 | var/ 33 | -------------------------------------------------------------------------------- /.meta.toml: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | [meta] 4 | template = "zope-product" 5 | commit-id = "1dc6b9e7" 6 | 7 | [python] 8 | with-pypy = false 9 | with-sphinx-doctests = false 10 | with-windows = false 11 | with-future-python = false 12 | with-macos = false 13 | 14 | [coverage] 15 | fail-under = 65 16 | 17 | [tox] 18 | additional-envlist = [ 19 | "py{37,38,39}-sqlalchemy11", 20 | "py{37,38,39,310}-sqlalchemy{12,13}", 21 | "py{37,38,39,310,311}-sqlalchemy{14,20}", 22 | ] 23 | testenv-deps = [ 24 | "sqlalchemy11: SQLAlchemy==1.1.*", 25 | "sqlalchemy12: SQLAlchemy==1.2.*", 26 | "sqlalchemy13: SQLAlchemy==1.3.*", 27 | "sqlalchemy14: SQLAlchemy==1.4.*", 28 | "sqlalchemy20: SQLAlchemy==2.0.*", 29 | ] 30 | testenv-commands-pre = [ 31 | "!sqlalchemy20: sh -c 'if [ '{env:CI:false}' = 'true' ]; then {envbindir}/buildout -nc {toxinidir}/github_actions.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi'", 32 | "!sqlalchemy20: sh -c 'if [ '{env:CI:false}' != 'true' ]; then {envbindir}/buildout -nc {toxinidir}/postgres.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi'", 33 | "sqlalchemy20: sh -c 'if [ '{env:CI:false}' = 'true' ]; then {envbindir}/buildout -nc {toxinidir}/github_actions20.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi'", 34 | "sqlalchemy20: sh -c 'if [ '{env:CI:false}' != 'true' ]; then {envbindir}/buildout -nc {toxinidir}/postgres20.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi'", 35 | ] 36 | testenv-commands = [ 37 | "{envbindir}/test {posargs:-cv}", 38 | "sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg {posargs:-cv} ; fi'", 39 | "sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg2 {posargs:-cv} ; fi'", 40 | "sqlalchemy20: sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg3 {posargs:-cv} ; fi'", 41 | "sqlalchemy20: sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg32 {posargs:-cv} ; fi'", 42 | ] 43 | testenv-additional = [ 44 | "passenv =", 45 | " CI", 46 | " TEST_PG", 47 | "allowlist_externals =", 48 | " sh", 49 | ] 50 | use-flake8 = true 51 | 52 | [manifest] 53 | additional-rules = [ 54 | "include github_actions.cfg", 55 | "include github_actions20.cfg", 56 | "include mysql.cfg", 57 | "include oracle.cfg", 58 | "include postgres.cfg", 59 | "include postgres20.cfg", 60 | "include pysqlite.cfg", 61 | "recursive-include src *.rst", 62 | ] 63 | 64 | [github-actions] 65 | steps-before-checkout = [ 66 | "- name: \"Configure PostgreSQL\"", 67 | " run: |", 68 | " sudo mkdir -p /usr/local/pgsql/data", 69 | " sudo chown postgres /usr/local/pgsql/data", 70 | " sudo su - postgres -c '/usr/lib/postgresql/14/bin/initdb -D /usr/local/pgsql/data'", 71 | " sudo su - postgres -c 'echo \"max_prepared_transactions=10\" >> /usr/local/pgsql/data/postgresql.conf'", 72 | " sudo su - postgres -c 'cat /usr/local/pgsql/data/postgresql.conf'", 73 | " sudo su - postgres -c '/usr/lib/postgresql/14/bin/pg_ctl -D /usr/local/pgsql/data -l logfile start'", 74 | " sudo su - postgres -c '/usr/lib/postgresql/14/bin/createdb zope_sqlalchemy_tests'", 75 | " sudo su - postgres -c '/usr/lib/postgresql/14/bin/psql -l'", 76 | ] 77 | test-commands = [ 78 | "tox -f ${{ matrix.config[1] }}", 79 | ] 80 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 3.2 (unreleased) 5 | ---------------- 6 | 7 | - SQLAlchemy's versions 2.0.32 up to 2.0.35 run into dead locks when running 8 | the tests on Python 3.11+, so excluding them from the list of supported 9 | versions. 10 | (`#84 `_) 11 | 12 | 3.1 (2023-09-12) 13 | ---------------- 14 | 15 | - Fix ``psycopg.errors.OperationalError.sqlstate`` can be ``None``. 16 | (`#81 `_) 17 | 18 | 19 | 3.0 (2023-06-01) 20 | ---------------- 21 | 22 | - Add support for SQLAlchemy 2.0 and for new psycopg v3 backend. 23 | (`#79 `_) 24 | 25 | **Breaking Changes** 26 | 27 | - No longer allow calling ``session.commit()`` within a manual nested database 28 | transaction (a savepoint). If you want to use savepoints directly in code that is 29 | not aware of ``transaction.savepoint()`` with ``session.begin_nested()`` then 30 | use the savepoint returned by the function to commit just the nested transaction 31 | i.e. ``savepoint = session.begin_nested(); savepoint.commit()`` or use it as a 32 | context manager i.e. ``with session.begin_nested():``. 33 | (`for details see #79 `_) 34 | 35 | 36 | 2.0 (2023-02-06) 37 | ---------------- 38 | 39 | - Drop support for Python 2.7, 3.5, 3.6. 40 | 41 | - Drop support for ``SQLAlchemy < 1.1`` 42 | (`#65 `_) 43 | 44 | - Add support for Python 3.10, 3.11. 45 | 46 | 47 | 1.6 (2021-09-06) 48 | ---------------- 49 | 50 | - Add support for Python 2.7 on SQLAlchemy 1.4. 51 | (`#71 `_) 52 | 53 | 54 | 1.5 (2021-07-14) 55 | ---------------- 56 | 57 | - Call ``mark_changed`` also on the ``do_orm_execute`` event if the operation 58 | is an insert, update or delete. This is SQLAlchemy >= 1.4 only, as it 59 | introduced that event. 60 | (`#67 `_) 61 | 62 | - Fixup get transaction. There was regression introduced in 1.4. 63 | (`#66 `_) 64 | 65 | 66 | 1.4 (2021-04-26) 67 | ---------------- 68 | 69 | - Add ``mark_changed`` and ``join_transaction`` methods to 70 | ``ZopeTransactionEvents``. 71 | (`#46 `_) 72 | 73 | - Reduce DeprecationWarnings with SQLAlchemy 1.4 and require at least 74 | SQLAlchemy >= 0.9. 75 | (`#54 `_) 76 | 77 | - Add support for SQLAlchemy 1.4. 78 | (`#58 `_) 79 | 80 | - Prevent using an SQLAlchemy 1.4 version with broken flush support. 81 | (`#57 `_) 82 | 83 | 84 | 1.3 (2020-02-17) 85 | ---------------- 86 | 87 | * ``.datamanager.register()`` now returns the ``ZopeTransactionEvents`` 88 | instance which was used to register the events. This allows to change its 89 | parameters afterwards. 90 | (`#40 `_) 91 | 92 | * Add preliminary support for Python 3.9a3. 93 | 94 | 95 | 1.2 (2019-10-17) 96 | ---------------- 97 | 98 | **Breaking Changes** 99 | 100 | * Drop support for Python 3.4. 101 | 102 | * Add support for Python 3.7 and 3.8. 103 | 104 | * Fix deprecation warnings for the event system. We already used it in general 105 | but still leveraged the old extension mechanism in some places. 106 | (`#31 `_) 107 | 108 | To make things clearer we renamed the ``ZopeTransactionExtension`` class 109 | to ``ZopeTransactionEvents``. Existing code using the 'register' version 110 | stays compatible. 111 | 112 | **Upgrade from 1.1** 113 | 114 | Your old code like this: 115 | 116 | .. code-block:: python 117 | 118 | from zope.sqlalchemy import ZopeTransactionExtension 119 | 120 | DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension(), **options)) 121 | 122 | becomes: 123 | 124 | .. code-block:: python 125 | 126 | from zope.sqlalchemy import register 127 | 128 | DBSession = scoped_session(sessionmaker(**options)) 129 | register(DBSession) 130 | 131 | 132 | 133 | 1.1 (2019-01-03) 134 | ---------------- 135 | 136 | * Add support to MySQL using pymysql. 137 | 138 | 139 | 1.0 (2018-01-31) 140 | ---------------- 141 | 142 | * Add support for Python 3.4 up to 3.6. 143 | 144 | * Support SQLAlchemy 1.2. 145 | 146 | * Drop support for Python 2.6, 3.2 and 3.3. 147 | 148 | * Drop support for transaction < 1.6.0. 149 | 150 | * Fix hazard that could cause SQLAlchemy session not to be committed when 151 | transaction is committed in rare situations. 152 | (`#23 `_) 153 | 154 | 155 | 0.7.7 (2016-06-23) 156 | ------------------ 157 | 158 | * Support SQLAlchemy 1.1. 159 | (`#15 `_) 160 | 161 | 162 | 0.7.6 (2015-03-20) 163 | ------------------ 164 | 165 | * Make version check in register compatible with prereleases. 166 | 167 | 0.7.5 (2014-06-17) 168 | ------------------ 169 | 170 | * Ensure mapped objects are expired following a ``transaction.commit()`` when 171 | no database commit was required. 172 | (`#8 `_) 173 | 174 | 175 | 0.7.4 (2014-01-06) 176 | ------------------ 177 | 178 | * Allow ``session.commit()`` on nested transactions to facilitate integration 179 | of existing code that might not use ``transaction.savepoint()``. 180 | (`#1 `_) 181 | 182 | * Add a new function zope.sqlalchemy.register(), which replaces the 183 | direct use of ZopeTransactionExtension to make use 184 | of the newer SQLAlchemy event system to establish instrumentation on 185 | the given Session instance/class/factory. Requires at least 186 | SQLAlchemy 0.7. 187 | (`#4 `_) 188 | 189 | * Fix `keep_session=True` doesn't work when a transaction is joined by flush 190 | and other manngers bug. 191 | (`#5 `_) 192 | 193 | 194 | 0.7.3 (2013-09-25) 195 | ------------------ 196 | 197 | * Prevent the ``Session`` object from getting into a "wedged" state if joining 198 | a transaction fails. With thread scoped sessions that are reused this can cause 199 | persistent errors requiring a server restart. 200 | (`#2 `_) 201 | 202 | 0.7.2 (2013-02-19) 203 | ------------------ 204 | 205 | * Make life-time of sessions configurable. Specify `keep_session=True` when 206 | setting up the SA extension. 207 | 208 | * Python 3.3 compatibility. 209 | 210 | 0.7.1 (2012-05-19) 211 | ------------------ 212 | 213 | * Use ``@implementer`` as a class decorator instead of ``implements()`` at 214 | class scope for compatibility with ``zope.interface`` 4.0. This requires 215 | ``zope.interface`` >= 3.6.0. 216 | 217 | 0.7 (2011-12-06) 218 | ---------------- 219 | 220 | * Python 3.2 compatibility. 221 | 222 | 0.6.1 (2011-01-08) 223 | ------------------ 224 | 225 | * Update datamanager.mark_changed to handle sessions which have not yet logged 226 | a (ORM) query. 227 | 228 | 229 | 0.6 (2010-07-24) 230 | ---------------- 231 | 232 | * Implement should_retry for sqlalchemy.orm.exc.ConcurrentModificationError 233 | and serialization errors from PostgreSQL and Oracle. 234 | (Specify transaction>=1.1 to use this functionality.) 235 | 236 | * Include license files. 237 | 238 | * Add ``transaction_manager`` attribute to data managers for compliance with 239 | IDataManager interface. 240 | 241 | 0.5 (2010-06-07) 242 | ---------------- 243 | 244 | * Remove redundant session.flush() / session.clear() on savepoint operations. 245 | These were only needed with SQLAlchemy 0.4.x. 246 | 247 | * SQLAlchemy 0.6.x support. Require SQLAlchemy >= 0.5.1. 248 | 249 | * Add support for running ``python setup.py test``. 250 | 251 | * Pull in pysqlite explicitly as a test dependency. 252 | 253 | * Setup sqlalchemy mappers in test setup and clear them in tear down. This 254 | makes the tests more robust and clears up the global state after. It 255 | caused the tests to fail when other tests in the same run called 256 | clear_mappers. 257 | 258 | 0.4 (2009-01-20) 259 | ---------------- 260 | 261 | Bugs fixed: 262 | 263 | * Only raise errors in tpc_abort if we have committed. 264 | 265 | * Remove the session id from the SESSION_STATE just before we de-reference the 266 | session (i.e. all work is already successfuly completed). This fixes cases 267 | where the transaction commit failed but SESSION_STATE was already cleared. In 268 | those cases, the transaction was wedeged as abort would always error. This 269 | happened on PostgreSQL where invalid SQL was used and the error caught. 270 | 271 | * Call session.flush() unconditionally in tpc_begin. 272 | 273 | * Change error message on session.commit() to be friendlier to non zope users. 274 | 275 | Feature changes: 276 | 277 | * Support for bulk update and delete with SQLAlchemy 0.5.1 278 | 279 | 0.3 (2008-07-29) 280 | ---------------- 281 | 282 | Bugs fixed: 283 | 284 | * New objects added to a session did not cause a transaction join, so were not 285 | committed at the end of the transaction unless the database was accessed. 286 | SQLAlchemy 0.4.7 or 0.5beta3 now required. 287 | 288 | Feature changes: 289 | 290 | * For correctness and consistency with ZODB, renamed the function 'invalidate' 291 | to 'mark_changed' and the status 'invalidated' to 'changed'. 292 | 293 | 0.2 (2008-06-28) 294 | ---------------- 295 | 296 | Feature changes: 297 | 298 | * Updated to support SQLAlchemy 0.5. (0.4.6 is still supported). 299 | 300 | 0.1 (2008-05-15) 301 | ---------------- 302 | 303 | * Initial public release. 304 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 5 | # Contributing to zopefoundation projects 6 | 7 | The projects under the zopefoundation GitHub organization are open source and 8 | welcome contributions in different forms: 9 | 10 | * bug reports 11 | * code improvements and bug fixes 12 | * documentation improvements 13 | * pull request reviews 14 | 15 | For any changes in the repository besides trivial typo fixes you are required 16 | to sign the contributor agreement. See 17 | https://www.zope.dev/developer/becoming-a-committer.html for details. 18 | 19 | Please visit our [Developer 20 | Guidelines](https://www.zope.dev/developer/guidelines.html) if you'd like to 21 | contribute code changes and our [guidelines for reporting 22 | bugs](https://www.zope.dev/developer/reporting-bugs.html) if you want to file a 23 | bug report. 24 | -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | Zope Foundation and Contributors -------------------------------------------------------------------------------- /CREDITS.rst: -------------------------------------------------------------------------------- 1 | zope.sqlalchemy credits 2 | *********************** 3 | 4 | * Laurence Rowe - creator and main developer 5 | 6 | * Martijn Faassen - updated to work with SQLAlchemy 0.5 7 | 8 | Also thanks to Michael Bayer for help with integration in SQLAlchemy 9 | and of course SQLAlchemy itself, as well as the many Zope developers 10 | who worked on Zope/SQLAlchemy integration projects. 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Zope Public License (ZPL) Version 2.1 2 | 3 | A copyright notice accompanies this license document that identifies the 4 | copyright holders. 5 | 6 | This license has been certified as open source. It has also been designated as 7 | GPL compatible by the Free Software Foundation (FSF). 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions are met: 11 | 12 | 1. Redistributions in source code must retain the accompanying copyright 13 | notice, this list of conditions, and the following disclaimer. 14 | 15 | 2. Redistributions in binary form must reproduce the accompanying copyright 16 | notice, this list of conditions, and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | 3. Names of the copyright holders must not be used to endorse or promote 20 | products derived from this software without prior written permission from the 21 | copyright holders. 22 | 23 | 4. The right to distribute this software or to use it for any purpose does not 24 | give you the right to use Servicemarks (sm) or Trademarks (tm) of the 25 | copyright 26 | holders. Use of them is covered by separate agreement with the copyright 27 | holders. 28 | 29 | 5. If any files are modified, you must cause the modified files to carry 30 | prominent notices stating that you changed the files and the date of any 31 | change. 32 | 33 | Disclaimer 34 | 35 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED 36 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 37 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 38 | EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, 39 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 40 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 41 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 42 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 43 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 44 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 45 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | include *.md 4 | include *.rst 5 | include *.txt 6 | include buildout.cfg 7 | include tox.ini 8 | 9 | recursive-include src *.py 10 | include github_actions.cfg 11 | include github_actions20.cfg 12 | include mysql.cfg 13 | include oracle.cfg 14 | include postgres.cfg 15 | include postgres20.cfg 16 | include pysqlite.cfg 17 | recursive-include src *.rst 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build status|_ 2 | 3 | .. |Build status| image:: https://github.com/zopefoundation/zope.sqlalchemy/actions/workflows/tests.yml/badge.svg 4 | .. _Build status: https://github.com/zopefoundation/zope.sqlalchemy/actions/workflows/tests.yml 5 | 6 | 7 | See src/zope/sqlalchemy/README.rst 8 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | develop = . 3 | parts = test scripts 4 | show-picked-versions = true 5 | 6 | [test] 7 | recipe = zc.recipe.testrunner 8 | eggs = zope.sqlalchemy [test] 9 | defaults = ['--auto-color', '-s', 'zope.sqlalchemy'] 10 | 11 | [scripts] 12 | recipe = zc.recipe.egg 13 | eggs = 14 | ${test:eggs} 15 | collective.checkdocs 16 | interpreter = py 17 | -------------------------------------------------------------------------------- /github_actions.cfg: -------------------------------------------------------------------------------- 1 | # This config is intended for the use of github actions as the ident 2 | # auth-method does not work well with containers. 3 | [buildout] 4 | extends = postgres.cfg 5 | 6 | [pgenv] 7 | TEST_DSN = postgresql+psycopg2://postgres:postgres@localhost/zope_sqlalchemy_tests 8 | -------------------------------------------------------------------------------- /github_actions20.cfg: -------------------------------------------------------------------------------- 1 | # This config is intended for the use of github actions as the ident 2 | # auth-method does not work well with containers. 3 | [buildout] 4 | extends = postgres20.cfg 5 | 6 | [pgenv] 7 | TEST_DSN = postgresql+psycopg2://postgres:postgres@localhost/zope_sqlalchemy_tests 8 | 9 | [pgenv3] 10 | TEST_DSN = postgresql+psycopg://postgres:postgres@localhost/zope_sqlalchemy_tests 11 | -------------------------------------------------------------------------------- /mysql.cfg: -------------------------------------------------------------------------------- 1 | # bin/buildout -c mysql.cfg 2 | # mysql -u root -e "create database zope_sqlalchemy_tests" 3 | [buildout] 4 | extends = buildout.cfg 5 | parts += 6 | testmysql 7 | 8 | [test] 9 | eggs += pymysql 10 | 11 | [testmysql] 12 | <= test 13 | environment = mysqlenv 14 | 15 | [mysqlenv] 16 | TEST_DSN = mysql+pymysql:///zope_sqlalchemy_tests 17 | -------------------------------------------------------------------------------- /oracle.cfg: -------------------------------------------------------------------------------- 1 | # To run the oracle tests I use the oracle developer days VirtualBox image: 2 | # http://www.oracle.com/technology/software/products/virtualbox/appliances/index.html 3 | # For cx_Oracle to build, download instantclient basiclite and sdk from: 4 | # http://www.oracle.com/technology/software/tech/oci/instantclient/index.html 5 | 6 | [buildout] 7 | extends = buildout.cfg 8 | # extends = postgres.cfg 9 | parts += python-oracle cx_Oracle testora 10 | python = python-oracle 11 | 12 | [python-oracle] 13 | recipe = gocept.cxoracle 14 | instant-client = ${buildout:directory}/instantclient-basiclite-10.2.0.4.0-macosx-x64.zip 15 | instant-sdk = instantclient-sdk-10.2.0.4.0-macosx-x64.zip 16 | 17 | [cx_Oracle] 18 | recipe = zc.recipe.egg:custom 19 | egg = cx_Oracle 20 | 21 | [test] 22 | eggs += cx_Oracle 23 | 24 | [testora] 25 | <= test 26 | environment = oraenv 27 | 28 | [scripts] 29 | eggs += cx_Oracle 30 | 31 | [oraenv] 32 | TEST_DSN = oracle://system:oracle@192.168.56.101/orcl 33 | -------------------------------------------------------------------------------- /postgres.cfg: -------------------------------------------------------------------------------- 1 | # PATH=/opt/local/lib/postgresql90/bin:$PATH bin/buildout -c postgres.cfg 2 | # sudo -u postgres /opt/local/lib/postgresql90/bin/createdb zope_sqlalchemy_tests 3 | # sudo -u postgres /opt/local/lib/postgresql90/bin/createuser -s 4 | # sudo -u postgres /opt/local/lib/postgresql90/bin/postgres -D /opt/local/var/db/postgresql90/defaultdb -d 1 5 | [buildout] 6 | extends = buildout.cfg 7 | find-links = http://initd.org/pub/software/psycopg/ 8 | parts += 9 | testpg 10 | testpg2 11 | 12 | [testpg] 13 | <= test 14 | eggs += psycopg2 15 | environment = pgenv 16 | 17 | [testpg2] 18 | <= testpg 19 | environment = pgenv2 20 | 21 | [scripts] 22 | eggs += psycopg2 23 | 24 | [pgenv] 25 | TEST_DSN = postgresql+psycopg2:///zope_sqlalchemy_tests 26 | 27 | [pgenv2] 28 | <= pgenv 29 | TEST_TWOPHASE=True 30 | -------------------------------------------------------------------------------- /postgres20.cfg: -------------------------------------------------------------------------------- 1 | # PATH=/opt/local/lib/postgresql90/bin:$PATH bin/buildout -c postgres.cfg 2 | # sudo -u postgres /opt/local/lib/postgresql90/bin/createdb zope_sqlalchemy_tests 3 | # sudo -u postgres /opt/local/lib/postgresql90/bin/createuser -s 4 | # sudo -u postgres /opt/local/lib/postgresql90/bin/postgres -D /opt/local/var/db/postgresql90/defaultdb -d 1 5 | [buildout] 6 | extends = postgres.cfg 7 | parts += 8 | testpg3 9 | testpg32 10 | 11 | [testpg3] 12 | <= test 13 | eggs += psycopg[c] 14 | environment = pgenv3 15 | 16 | [testpg32] 17 | <= testpg3 18 | environment = pgenv32 19 | 20 | [scripts] 21 | eggs += 22 | psycopg[c] 23 | 24 | [pgenv3] 25 | TEST_DSN = postgresql+psycopg:///zope_sqlalchemy_tests 26 | 27 | [pgenv32] 28 | <= pgenv3 29 | TEST_TWOPHASE=True 30 | -------------------------------------------------------------------------------- /pysqlite.cfg: -------------------------------------------------------------------------------- 1 | # See: https://code.google.com/p/pysqlite-static-env/ 2 | 3 | [buildout] 4 | extends = buildout.cfg 5 | parts = 6 | pysqlite 7 | test 8 | scripts 9 | versions = versions 10 | 11 | [versions] 12 | pysqlite = 2.6.3-static-env-savepoints 13 | 14 | [test] 15 | eggs += pysqlite 16 | 17 | [pysqlite] 18 | recipe = zc.recipe.egg:custom 19 | environment = pysqlite-env 20 | find-links = http://pysqlite-static-env.googlecode.com/files/pysqlite-2.6.3-static-env-savepoints.tar.gz 21 | 22 | [pysqlite-env] 23 | STATICBUILD = true 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | [bdist_wheel] 4 | universal = 0 5 | 6 | [flake8] 7 | doctests = 1 8 | no-accept-encodings = True 9 | htmldir = parts/flake8 10 | 11 | [check-manifest] 12 | ignore = 13 | .editorconfig 14 | .meta.toml 15 | 16 | [isort] 17 | force_single_line = True 18 | combine_as_imports = True 19 | sections = FUTURE,STDLIB,THIRDPARTY,ZOPE,FIRSTPARTY,LOCALFOLDER 20 | known_third_party = six, docutils, pkg_resources, pytz 21 | known_zope = 22 | known_first_party = 23 | default_section = ZOPE 24 | line_length = 79 25 | lines_after_imports = 2 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import find_packages 4 | from setuptools import setup 5 | 6 | 7 | tests_require = ['zope.testing'] 8 | 9 | setup( 10 | name='zope.sqlalchemy', 11 | version='3.2.dev0', 12 | packages=find_packages('src'), 13 | package_dir={'': 'src'}, 14 | include_package_data=True, 15 | zip_safe=False, 16 | namespace_packages=['zope'], 17 | test_suite='zope.sqlalchemy.tests.test_suite', 18 | author='Laurence Rowe', 19 | 20 | author_email='laurence@lrowe.co.uk', 21 | url='https://github.com/zopefoundation/zope.sqlalchemy', 22 | description="Minimal Zope/SQLAlchemy transaction integration", 23 | long_description=( 24 | open(os.path.join('src', 'zope', 'sqlalchemy', 'README.rst')).read() + 25 | "\n\n" + 26 | open('CHANGES.rst').read()), 27 | license='ZPL 2.1', 28 | keywords='zope zope3 sqlalchemy', 29 | classifiers=[ 30 | "Development Status :: 5 - Production/Stable", 31 | "Framework :: Pyramid", 32 | "Framework :: Zope :: 3", 33 | "Framework :: Zope :: 5", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: Zope Public License", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3.9", 41 | "Programming Language :: Python :: 3.10", 42 | "Programming Language :: Python :: 3.11", 43 | "Programming Language :: Python :: Implementation :: CPython", 44 | "Topic :: Database", 45 | "Topic :: Software Development :: Libraries :: Python Modules", 46 | ], 47 | python_requires='>=3.7', 48 | install_requires=[ 49 | 'packaging', 50 | 'setuptools', 51 | 'SQLAlchemy>=1.1,!=1.4.0,!=1.4.1,!=1.4.2,!=1.4.3,!=1.4.4,!=1.4.5,!=1.4.6,!=2.0.32,!=2.0.33,!=2.0.34,!=2.0.35', # noqa: E501 line too long 52 | 'transaction>=1.6.0', 53 | 'zope.interface>=3.6.0', 54 | ], 55 | extras_require={'test': tests_require}, 56 | tests_require=tests_require, 57 | ) 58 | -------------------------------------------------------------------------------- /src/zope/__init__.py: -------------------------------------------------------------------------------- 1 | __import__('pkg_resources').declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /src/zope/sqlalchemy/README.rst: -------------------------------------------------------------------------------- 1 | *************** 2 | zope.sqlalchemy 3 | *************** 4 | 5 | .. contents:: 6 | :local: 7 | 8 | Introduction 9 | ============ 10 | 11 | The aim of this package is to unify the plethora of existing packages 12 | integrating SQLAlchemy with Zope's transaction management. As such it seeks 13 | only to provide a data manager and makes no attempt to define a `zopeish` way 14 | to configure engines. 15 | 16 | For WSGI applications, Zope style automatic transaction management is 17 | available with `repoze.tm2`_ (used by `Turbogears 2`_ and other systems). 18 | 19 | This package is also used by `pyramid_tm`_ (an add-on of the `Pyramid`_) web 20 | framework. 21 | 22 | You need to understand `SQLAlchemy`_ and the `Zope transaction manager`_ for 23 | this package and this README to make any sense. 24 | 25 | .. _repoze.tm2: https://repozetm2.readthedocs.io/en/latest/ 26 | 27 | .. _pyramid_tm: https://docs.pylonsproject.org/projects/pyramid_tm/en/latest/ 28 | 29 | .. _Pyramid: https://pylonsproject.org/ 30 | 31 | .. _Turbogears 2: https://turbogears.org/ 32 | 33 | .. _SQLAlchemy: https://sqlalchemy.org/docs/ 34 | 35 | .. _Zope transaction manager: https://www.zodb.org/en/latest/#transactions 36 | 37 | Running the tests 38 | ================= 39 | 40 | This package is distributed as a buildout. Using your desired python run: 41 | 42 | $ python bootstrap.py 43 | $ ./bin/buildout 44 | 45 | This will download the dependent packages and setup the test script, which may 46 | be run with: 47 | 48 | $ ./bin/test 49 | 50 | or with the standard setuptools test command: 51 | 52 | $ ./bin/py setup.py test 53 | 54 | To enable testing with your own database set the TEST_DSN environment variable 55 | to your sqlalchemy database dsn. Two-phase commit behaviour may be tested by 56 | setting the TEST_TWOPHASE variable to a non empty string. e.g: 57 | 58 | $ TEST_DSN=postgres://test:test@localhost/test TEST_TWOPHASE=True bin/test 59 | 60 | Usage in short 61 | ============== 62 | 63 | The integration between Zope transactions and the SQLAlchemy event system is 64 | done using the ``register()`` function on the session factory class. 65 | 66 | .. code-block:: python 67 | 68 | from zope.sqlalchemy import register 69 | from sqlalchemy import create_engine 70 | from sqlalchemy.orm import sessionmaker, scoped_session 71 | 72 | engine = sqlalchemy.create_engine("postgresql://scott:tiger@localhost/test") 73 | 74 | DBSession = scoped_session(sessionmaker(bind=engine)) 75 | register(DBSession) 76 | 77 | Instantiated sessions commits and rollbacks will now be integrated with Zope 78 | transactions. 79 | 80 | .. code-block:: python 81 | 82 | import transaction 83 | from sqlalchemy.sql import text 84 | 85 | session = DBSession() 86 | 87 | result = session.execute(text("DELETE FROM objects WHERE id=:id"), {"id": 2}) 88 | row = result.fetchone() 89 | 90 | transaction.commit() 91 | 92 | 93 | Full Example 94 | ============ 95 | 96 | This example is lifted directly from the SQLAlchemy declarative documentation. 97 | First the necessary imports. 98 | 99 | >>> from sqlalchemy import * 100 | >>> from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker, relationship 101 | >>> from sqlalchemy.sql import text 102 | >>> from zope.sqlalchemy import register 103 | >>> import transaction 104 | 105 | Now to define the mapper classes. 106 | 107 | >>> Base = declarative_base() 108 | >>> class User(Base): 109 | ... __tablename__ = 'test_users' 110 | ... id = Column('id', Integer, primary_key=True) 111 | ... name = Column('name', String(50)) 112 | ... addresses = relationship("Address", backref="user") 113 | >>> class Address(Base): 114 | ... __tablename__ = 'test_addresses' 115 | ... id = Column('id', Integer, primary_key=True) 116 | ... email = Column('email', String(50)) 117 | ... user_id = Column('user_id', Integer, ForeignKey('test_users.id')) 118 | 119 | Create an engine and setup the tables. Note that for this example to work a 120 | recent version of sqlite/pysqlite is required. 3.4.0 seems to be sufficient. 121 | 122 | >>> engine = create_engine(TEST_DSN) 123 | >>> Base.metadata.create_all(engine) 124 | 125 | Now to create the session itself. As zope is a threaded web server we must use 126 | scoped sessions. Zope and SQLAlchemy sessions are tied together by using the 127 | register 128 | 129 | >>> Session = scoped_session(sessionmaker(bind=engine, 130 | ... twophase=TEST_TWOPHASE)) 131 | 132 | Call the scoped session factory to retrieve a session. You may call this as 133 | many times as you like within a transaction and you will always retrieve the 134 | same session. At present there are no users in the database. 135 | 136 | >>> session = Session() 137 | >>> register(session) 138 | 139 | >>> session.query(User).all() 140 | [] 141 | 142 | We can now create a new user and commit the changes using Zope's transaction 143 | machinery, just as Zope's publisher would. 144 | 145 | >>> session.add(User(id=1, name='bob')) 146 | >>> transaction.commit() 147 | 148 | Engine level connections are outside the scope of the transaction integration. 149 | 150 | >>> engine.connect().execute(text('SELECT * FROM test_users')).fetchall() 151 | [(1, ...'bob')] 152 | 153 | A new transaction requires a new session. Let's add an address. 154 | 155 | >>> session = Session() 156 | >>> bob = session.query(User).all()[0] 157 | >>> str(bob.name) 158 | 'bob' 159 | >>> bob.addresses 160 | [] 161 | >>> bob.addresses.append(Address(id=1, email='bob@bob.bob')) 162 | >>> transaction.commit() 163 | >>> session = Session() 164 | >>> bob = session.query(User).all()[0] 165 | >>> bob.addresses 166 | [
] 167 | >>> str(bob.addresses[0].email) 168 | 'bob@bob.bob' 169 | >>> bob.addresses[0].email = 'wrong@wrong' 170 | 171 | To rollback a transaction, use transaction.abort(). 172 | 173 | >>> transaction.abort() 174 | >>> session = Session() 175 | >>> bob = session.query(User).all()[0] 176 | >>> str(bob.addresses[0].email) 177 | 'bob@bob.bob' 178 | >>> transaction.abort() 179 | 180 | By default, zope.sqlalchemy puts sessions in an 'active' state when they are 181 | first used. ORM write operations automatically move the session into a 182 | 'changed' state. This avoids unnecessary database commits. Sometimes it 183 | is necessary to interact with the database directly through SQL. It is not 184 | possible to guess whether such an operation is a read or a write. Therefore we 185 | must manually mark the session as changed when manual SQL statements write 186 | to the DB. 187 | 188 | >>> session = Session() 189 | >>> conn = session.connection() 190 | >>> users = Base.metadata.tables['test_users'] 191 | >>> conn.execute(users.update().where(users.c.name=='bob'), {'name': 'ben'}) 192 | 193 | >>> from zope.sqlalchemy import mark_changed 194 | >>> mark_changed(session) 195 | >>> transaction.commit() 196 | >>> session = Session() 197 | >>> str(session.query(User).all()[0].name) 198 | 'ben' 199 | >>> transaction.abort() 200 | 201 | If this is a problem you may register the events and tell them to place the 202 | session in the 'changed' state initially. 203 | 204 | >>> Session.remove() 205 | >>> register(Session, 'changed') 206 | 207 | >>> session = Session() 208 | >>> conn = session.connection() 209 | >>> conn.execute(users.update().where(users.c.name=='ben'), {'name': 'bob'}) 210 | 211 | >>> transaction.commit() 212 | >>> session = Session() 213 | >>> str(session.query(User).all()[0].name) 214 | 'bob' 215 | >>> transaction.abort() 216 | 217 | The `mark_changed` function accepts a kwarg for `keep_session` which defaults 218 | to `False` and is unaware of the registered extensions `keep_session` 219 | configuration. 220 | 221 | If you intend for `keep_session` to be True, you can specify it explicitly: 222 | 223 | >>> from zope.sqlalchemy import mark_changed 224 | >>> mark_changed(session, keep_session=True) 225 | >>> transaction.commit() 226 | 227 | You can also use a configured extension to preserve this argument: 228 | 229 | >>> sessionExtension = register(session, keep_session=True) 230 | >>> sessionExtension.mark_changed(session) 231 | >>> transaction.commit() 232 | 233 | 234 | Long-lasting session scopes 235 | --------------------------- 236 | 237 | The default behaviour of the transaction integration is to close the session 238 | after a commit. You can tell by trying to access an object after committing: 239 | 240 | >>> bob = session.query(User).all()[0] 241 | >>> transaction.commit() 242 | >>> bob.name 243 | Traceback (most recent call last): 244 | sqlalchemy.orm.exc.DetachedInstanceError: Instance is not bound to a Session; attribute refresh operation cannot proceed... 245 | 246 | To support cases where a session needs to last longer than a transaction (useful 247 | in test suites) you can specify to keep a session when registering the events: 248 | 249 | >>> Session = scoped_session(sessionmaker(bind=engine, 250 | ... twophase=TEST_TWOPHASE)) 251 | >>> register(Session, keep_session=True) 252 | 253 | >>> session = Session() 254 | >>> bob = session.query(User).all()[0] 255 | >>> bob.name = 'bobby' 256 | >>> transaction.commit() 257 | >>> bob.name 258 | 'bobby' 259 | 260 | The session must then be closed manually: 261 | 262 | >>> session.close() 263 | 264 | 265 | Development version 266 | =================== 267 | 268 | `GIT version `_ 269 | -------------------------------------------------------------------------------- /src/zope/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2008 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE 12 | # 13 | ############################################################################## 14 | 15 | from zope.sqlalchemy.datamanager import ZopeTransactionEvents 16 | from zope.sqlalchemy.datamanager import mark_changed 17 | from zope.sqlalchemy.datamanager import register 18 | 19 | 20 | invalidate = mark_changed 21 | 22 | __all__ = [ 23 | 'ZopeTransactionEvents', 24 | 'invalidate', 25 | 'mark_changed', 26 | 'register', 27 | ] 28 | -------------------------------------------------------------------------------- /src/zope/sqlalchemy/datamanager.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2008 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE 12 | # 13 | ############################################################################## 14 | 15 | 16 | from weakref import WeakKeyDictionary 17 | 18 | import transaction as zope_transaction 19 | from packaging.version import Version as parse_version 20 | from sqlalchemy import __version__ as sqlalchemy_version 21 | from sqlalchemy.engine.base import Engine 22 | from sqlalchemy.exc import DBAPIError 23 | from sqlalchemy.orm.exc import ConcurrentModificationError 24 | from transaction._transaction import Status as ZopeStatus 25 | from transaction.interfaces import IDataManagerSavepoint 26 | from transaction.interfaces import ISavepointDataManager 27 | from zope.interface import implementer 28 | 29 | 30 | _retryable_errors = [] 31 | try: 32 | import psycopg2.extensions 33 | except ImportError: 34 | pass 35 | else: 36 | _retryable_errors.append( 37 | (psycopg2.extensions.TransactionRollbackError, None)) 38 | 39 | # Error Class 40: Transaction Rollback, for details 40 | # see https://www.psycopg.org/psycopg3/docs/api/errors.html 41 | try: 42 | import psycopg.errors 43 | except ImportError: 44 | pass 45 | else: 46 | _retryable_errors.append( 47 | (psycopg.errors.OperationalError, 48 | lambda e: e.sqlstate and e.sqlstate.startswith('40')) 49 | ) 50 | 51 | # ORA-08177: can't serialize access for this transaction 52 | try: 53 | import cx_Oracle 54 | except ImportError: 55 | pass 56 | else: 57 | _retryable_errors.append( 58 | (cx_Oracle.DatabaseError, lambda e: e.args[0].code == 8177) 59 | ) 60 | 61 | # 1213: Deadlock found when trying to get lock; try restarting transaction 62 | try: 63 | import pymysql 64 | except ImportError: 65 | pass 66 | else: 67 | _retryable_errors.append( 68 | (pymysql.err.OperationalError, lambda e: e.args[0] == 1213) 69 | ) 70 | 71 | # The status of the session is stored on the connection info 72 | STATUS_ACTIVE = "active" # session joined to transaction, writes allowed. 73 | STATUS_CHANGED = "changed" # data has been written 74 | # session joined to transaction, no writes allowed. 75 | STATUS_READONLY = "readonly" 76 | STATUS_INVALIDATED = STATUS_CHANGED # BBB 77 | 78 | NO_SAVEPOINT_SUPPORT = {"sqlite"} 79 | 80 | _SESSION_STATE = WeakKeyDictionary() # a mapping of session -> status 81 | # This is thread safe because you are using scoped sessions 82 | 83 | SA_GE_14 = parse_version(sqlalchemy_version) >= parse_version('1.4.0') 84 | 85 | 86 | # 87 | # The two variants of the DataManager. 88 | # 89 | 90 | 91 | @implementer(ISavepointDataManager) 92 | class SessionDataManager: 93 | """Integrate a top level sqlalchemy session transaction into a 94 | 95 | zope transaction. 96 | 97 | One phase variant. 98 | """ 99 | 100 | def __init__( 101 | self, session, status, transaction_manager, keep_session=False): 102 | self.transaction_manager = transaction_manager 103 | 104 | if SA_GE_14: 105 | root_transaction = session.get_transaction() or session.begin() 106 | else: 107 | # Support both SQLAlchemy 1.0 and 1.1 108 | # https://github.com/zopefoundation/zope.sqlalchemy/issues/15 109 | _iterate_parents = ( 110 | getattr(session.transaction, "_iterate_self_and_parents", None) 111 | or session.transaction._iterate_parents 112 | ) 113 | root_transaction = _iterate_parents()[-1] 114 | 115 | self.tx = root_transaction 116 | self.session = session 117 | transaction_manager.get().join(self) 118 | _SESSION_STATE[session] = status 119 | self.state = "init" 120 | self.keep_session = keep_session 121 | 122 | def _finish(self, final_state): 123 | assert self.tx is not None 124 | session = self.session 125 | del _SESSION_STATE[self.session] 126 | self.tx = self.session = None 127 | self.state = final_state 128 | # closing the session is the last thing we do. If it fails the 129 | # transactions don't get wedged and the error propagates 130 | if not self.keep_session: 131 | session.close() 132 | else: 133 | session.expire_all() 134 | 135 | def abort(self, trans): 136 | if self.tx is not None: # there may have been no work to do 137 | self._finish("aborted") 138 | 139 | def tpc_begin(self, trans): 140 | self.session.flush() 141 | 142 | def commit(self, trans): 143 | status = _SESSION_STATE[self.session] 144 | if status is not STATUS_INVALIDATED: 145 | session = self.session 146 | if session.expire_on_commit: 147 | session.expire_all() 148 | self._finish("no work") 149 | 150 | def tpc_vote(self, trans): 151 | # for a one phase data manager commit last in tpc_vote 152 | if self.tx is not None: # there may have been no work to do 153 | self.tx.commit() 154 | self._finish("committed") 155 | 156 | def tpc_finish(self, trans): 157 | pass 158 | 159 | def tpc_abort(self, trans): 160 | assert self.state != "committed" 161 | 162 | def sortKey(self): 163 | # Try to sort last, so that we vote last - we may commit in tpc_vote(), 164 | # which allows Zope to roll back its transaction if the RDBMS 165 | # threw a conflict error. 166 | return "~sqlalchemy:%d" % id(self.tx) 167 | 168 | @property 169 | def savepoint(self): 170 | """Savepoints are only supported when all connections support 171 | 172 | subtransactions. 173 | """ 174 | 175 | # ATT: the following check is weak since the savepoint capability 176 | # of a RDBMS also depends on its version. E.g. Postgres 7.X does not 177 | # support savepoints but Postgres is whitelisted independent of its 178 | # version. Possibly additional version information should be taken 179 | # into account (ajung) 180 | if { 181 | engine.url.drivername 182 | for engine in self.tx._connections.keys() 183 | if isinstance(engine, Engine) 184 | }.intersection(NO_SAVEPOINT_SUPPORT): 185 | raise AttributeError("savepoint") 186 | return self._savepoint 187 | 188 | def _savepoint(self): 189 | return SessionSavepoint(self.session) 190 | 191 | def should_retry(self, error): 192 | if isinstance(error, ConcurrentModificationError): 193 | return True 194 | if isinstance(error, DBAPIError): 195 | orig = error.orig 196 | for error_type, test in _retryable_errors: 197 | if isinstance(orig, error_type): 198 | if test is None: 199 | return True 200 | if test(orig): 201 | return True 202 | 203 | 204 | class TwoPhaseSessionDataManager(SessionDataManager): 205 | """Two phase variant. 206 | """ 207 | 208 | def tpc_vote(self, trans): 209 | if self.tx is not None: # there may have been no work to do 210 | self.tx.prepare() 211 | self.state = "voted" 212 | 213 | def tpc_finish(self, trans): 214 | if self.tx is not None: 215 | self.tx.commit() 216 | self._finish("committed") 217 | 218 | def tpc_abort(self, trans): 219 | # we may not have voted, and been aborted already 220 | if self.tx is not None: 221 | self.tx.rollback() 222 | self._finish("aborted commit") 223 | 224 | def sortKey(self): 225 | # Sort normally 226 | return "sqlalchemy.twophase:%d" % id(self.tx) 227 | 228 | 229 | @implementer(IDataManagerSavepoint) 230 | class SessionSavepoint: 231 | def __init__(self, session): 232 | self.session = session 233 | self.transaction = session.begin_nested() 234 | 235 | def rollback(self): 236 | # no need to check validity, sqlalchemy should raise an exception. 237 | self.transaction.rollback() 238 | 239 | 240 | def join_transaction( 241 | session, 242 | initial_state=STATUS_ACTIVE, 243 | transaction_manager=zope_transaction.manager, 244 | keep_session=False, 245 | ): 246 | """Join a session to a transaction using the appropriate datamanager. 247 | 248 | It is safe to call this multiple times, if the session is already joined 249 | then it just returns. 250 | 251 | `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or 252 | STATUS_READONLY 253 | 254 | If using the default initial status of STATUS_ACTIVE, you must ensure that 255 | mark_changed(session) is called when data is written to the database. 256 | 257 | The ZopeTransactionEvents can be used to ensure that this is 258 | called automatically after session write operations. 259 | """ 260 | if _SESSION_STATE.get(session, None) is None: 261 | if session.twophase: 262 | DataManager = TwoPhaseSessionDataManager 263 | else: 264 | DataManager = SessionDataManager 265 | DataManager( 266 | session, initial_state, transaction_manager, 267 | keep_session=keep_session 268 | ) 269 | 270 | 271 | def mark_changed( 272 | session, transaction_manager=zope_transaction.manager, keep_session=False 273 | ): 274 | """Mark a session as needing to be committed. 275 | """ 276 | assert ( 277 | _SESSION_STATE.get(session, None) is not STATUS_READONLY 278 | ), "Session already registered as read only" 279 | join_transaction(session, STATUS_CHANGED, 280 | transaction_manager, keep_session) 281 | _SESSION_STATE[session] = STATUS_CHANGED 282 | 283 | 284 | class ZopeTransactionEvents: 285 | """Record that a flush has occurred on a session's connection. This allows 286 | the DataManager to rollback rather than commit on read only transactions. 287 | """ 288 | 289 | def __init__( 290 | self, 291 | initial_state=STATUS_ACTIVE, 292 | transaction_manager=zope_transaction.manager, 293 | keep_session=False, 294 | ): 295 | if initial_state == "invalidated": 296 | initial_state = STATUS_CHANGED # BBB 297 | self.initial_state = initial_state 298 | self.transaction_manager = transaction_manager 299 | self.keep_session = keep_session 300 | 301 | def after_begin(self, session, transaction, connection): 302 | join_transaction( 303 | session, self.initial_state, self.transaction_manager, 304 | self.keep_session 305 | ) 306 | 307 | def after_attach(self, session, instance): 308 | join_transaction( 309 | session, self.initial_state, self.transaction_manager, 310 | self.keep_session 311 | ) 312 | 313 | def after_flush(self, session, flush_context): 314 | mark_changed(session, self.transaction_manager, self.keep_session) 315 | 316 | def after_bulk_update(self, update_context): 317 | mark_changed(update_context.session, 318 | self.transaction_manager, self.keep_session) 319 | 320 | def after_bulk_delete(self, delete_context): 321 | mark_changed(delete_context.session, 322 | self.transaction_manager, self.keep_session) 323 | 324 | def before_commit(self, session): 325 | in_nested_transaction = ( 326 | session.in_nested_transaction() 327 | if SA_GE_14 328 | # support sqlalchemy 1.3 and below 329 | else session.transaction.nested 330 | ) 331 | assert ( 332 | in_nested_transaction 333 | or self.transaction_manager.get().status == ZopeStatus.COMMITTING 334 | ), "Transaction must be committed using the transaction manager" 335 | 336 | def do_orm_execute(self, execute_state): 337 | dml = any((execute_state.is_update, execute_state.is_insert, 338 | execute_state.is_delete)) 339 | if execute_state.is_orm_statement and dml: 340 | mark_changed(execute_state.session, self.transaction_manager, 341 | self.keep_session) 342 | 343 | def mark_changed(self, session): 344 | """Developer interface to `mark_changed` that preserves the extension's 345 | active configuration. 346 | """ 347 | mark_changed(session, self.transaction_manager, self.keep_session) 348 | 349 | def join_transaction(self, session): 350 | """Developer interface to `join_transaction` that preserves the 351 | extension's active configuration. 352 | """ 353 | join_transaction( 354 | session, self.initial_state, self.transaction_manager, 355 | self.keep_session 356 | ) 357 | 358 | 359 | def register( 360 | session, 361 | initial_state=STATUS_ACTIVE, 362 | transaction_manager=zope_transaction.manager, 363 | keep_session=False, 364 | ): 365 | """Register ZopeTransaction listener events on the 366 | given Session or Session factory/class. 367 | 368 | This function requires at least SQLAlchemy 0.7 and makes use 369 | of the newer sqlalchemy.event package in order to register event listeners 370 | on the given Session. 371 | 372 | The session argument here may be a Session class or subclass, a 373 | sessionmaker or scoped_session instance, or a specific Session instance. 374 | Event listening will be specific to the scope of the type of argument 375 | passed, including specificity to its subclass as well as its identity. 376 | 377 | It returns the instance of ZopeTransactionEvents those methods where used 378 | to register the event listeners. 379 | 380 | """ 381 | from sqlalchemy import event 382 | 383 | ext = ZopeTransactionEvents( 384 | initial_state=initial_state, 385 | transaction_manager=transaction_manager, 386 | keep_session=keep_session, 387 | ) 388 | 389 | event.listen(session, "after_begin", ext.after_begin) 390 | event.listen(session, "after_attach", ext.after_attach) 391 | event.listen(session, "after_flush", ext.after_flush) 392 | event.listen(session, "after_bulk_update", ext.after_bulk_update) 393 | event.listen(session, "after_bulk_delete", ext.after_bulk_delete) 394 | event.listen(session, "before_commit", ext.before_commit) 395 | 396 | if SA_GE_14: 397 | event.listen(session, "do_orm_execute", ext.do_orm_execute) 398 | 399 | return ext 400 | -------------------------------------------------------------------------------- /src/zope/sqlalchemy/tests.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2008 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE 12 | # 13 | ############################################################################## 14 | 15 | # Inspiration from z3c.sqlalchemy/src/z3c/sqlalchemy/tests/testSQLAlchemy.py 16 | # 17 | # You may want to run the tests with your database. To do so set the 18 | # environment variable TEST_DSN to the connection url. e.g.: 19 | # export TEST_DSN=postgres://plone:plone@localhost/test 20 | # export TEST_DSN=mssql://plone:plone@/test?dsn=mydsn 21 | # 22 | # To test in twophase commit mode export TEST_TWOPHASE=True 23 | # 24 | # NOTE: The sqlite that ships with Mac OS X 10.4 is buggy. 25 | # Install a newer version (3.5.6) and rebuild pysqlite2 against it. 26 | 27 | import os 28 | import threading 29 | import time 30 | import unittest 31 | 32 | import sqlalchemy as sa 33 | import transaction 34 | from packaging.version import Version as parse_version 35 | from sqlalchemy import __version__ as sqlalchemy_version 36 | from sqlalchemy import exc 37 | from sqlalchemy import orm 38 | from sqlalchemy import sql 39 | from transaction._transaction import Status as ZopeStatus 40 | from transaction.interfaces import TransactionFailedError 41 | 42 | from zope.sqlalchemy import datamanager as tx 43 | from zope.sqlalchemy import mark_changed 44 | 45 | 46 | SA_GE_20 = parse_version(sqlalchemy_version) >= parse_version('2.0.0') 47 | TEST_TWOPHASE = bool(os.environ.get("TEST_TWOPHASE")) 48 | TEST_DSN = os.environ.get("TEST_DSN", "sqlite:///:memory:") 49 | 50 | 51 | class SimpleModel: 52 | def __init__(self, **kw): 53 | for k, v in kw.items(): 54 | setattr(self, k, v) 55 | 56 | def asDict(self): 57 | return {k: v 58 | for k, v in self.__dict__.items() 59 | if not k.startswith("_")} 60 | 61 | 62 | class User(SimpleModel): 63 | pass 64 | 65 | 66 | class Skill(SimpleModel): 67 | pass 68 | 69 | 70 | engine = sa.create_engine(TEST_DSN) 71 | 72 | # See https://code.google.com/p/pysqlite-static-env/ 73 | HAS_PATCHED_PYSQLITE = False 74 | if engine.url.drivername == "sqlite": 75 | try: 76 | from pysqlite2.dbapi2 import Connection 77 | except ImportError: 78 | pass 79 | else: 80 | if hasattr(Connection, "operation_needs_transaction_callback"): 81 | HAS_PATCHED_PYSQLITE = True 82 | 83 | if HAS_PATCHED_PYSQLITE: 84 | from sqlalchemy import event 85 | 86 | from zope.sqlalchemy.datamanager import NO_SAVEPOINT_SUPPORT 87 | 88 | NO_SAVEPOINT_SUPPORT.remove("sqlite") 89 | 90 | @event.listens_for(engine, "connect") 91 | def connect(dbapi_connection, connection_record): 92 | dbapi_connection.operation_needs_transaction_callback = lambda x: True 93 | 94 | 95 | Session = orm.scoped_session(orm.sessionmaker( 96 | bind=engine, twophase=TEST_TWOPHASE)) 97 | tx.register(Session) 98 | 99 | UnboundSession = orm.scoped_session(orm.sessionmaker(twophase=TEST_TWOPHASE)) 100 | tx.register(UnboundSession) 101 | 102 | EventSession = orm.scoped_session( 103 | orm.sessionmaker(bind=engine, twophase=TEST_TWOPHASE)) 104 | tx.register(EventSession) 105 | 106 | KeepSession = orm.scoped_session( 107 | orm.sessionmaker(bind=engine, twophase=TEST_TWOPHASE)) 108 | tx.register(KeepSession, keep_session=True) 109 | 110 | 111 | metadata = sa.MetaData() # best to use unbound metadata 112 | 113 | 114 | test_users = sa.Table( 115 | "test_users", 116 | metadata, 117 | sa.Column("id", sa.Integer, primary_key=True), 118 | # mssql cannot do equality on a text type 119 | sa.Column("firstname", sa.VARCHAR(255)), 120 | sa.Column("lastname", sa.VARCHAR(255)), 121 | ) 122 | 123 | test_skills = sa.Table( 124 | "test_skills", 125 | metadata, 126 | sa.Column("id", sa.Integer, primary_key=True), 127 | sa.Column("user_id", sa.Integer), 128 | sa.Column("name", sa.VARCHAR(255)), 129 | sa.ForeignKeyConstraint(("user_id",), ("test_users.id",)), 130 | ) 131 | 132 | if SA_GE_20: 133 | # bound metadata does no longer exist in SQLAlchemy 2.0 134 | test_one = sa.Table( 135 | "test_one", 136 | metadata, 137 | sa.Column("id", sa.Integer, primary_key=True) 138 | ) 139 | test_two = sa.Table( 140 | "test_two", 141 | metadata, 142 | sa.Column("id", sa.Integer, primary_key=True) 143 | ) 144 | else: 145 | engine2 = sa.create_engine(TEST_DSN) 146 | 147 | bound_metadata1 = sa.MetaData(engine) 148 | bound_metadata2 = sa.MetaData(engine2) 149 | 150 | test_one = sa.Table( 151 | "test_one", 152 | bound_metadata1, 153 | sa.Column("id", sa.Integer, primary_key=True) 154 | ) 155 | test_two = sa.Table( 156 | "test_two", 157 | bound_metadata2, 158 | sa.Column("id", sa.Integer, primary_key=True) 159 | ) 160 | 161 | 162 | class TestOne(SimpleModel): 163 | pass 164 | 165 | 166 | class TestTwo(SimpleModel): 167 | pass 168 | 169 | 170 | def setup_mappers(): 171 | orm.clear_mappers() 172 | # Other tests can clear mappers by calling clear_mappers(), 173 | # be more robust by setting up mappers in the test setup. 174 | 175 | if SA_GE_20: 176 | mapper_reg = orm.registry() 177 | mapper = mapper_reg.map_imperatively 178 | else: 179 | mapper = orm.mapper 180 | 181 | m1 = mapper( 182 | User, 183 | test_users, 184 | properties={ 185 | "skills": orm.relationship( 186 | Skill, 187 | primaryjoin=( 188 | test_users.columns["id"] == test_skills.columns["user_id"] 189 | ), 190 | ) 191 | }, 192 | ) 193 | m2 = mapper(Skill, test_skills) 194 | 195 | m3 = mapper(TestOne, test_one) 196 | m4 = mapper(TestTwo, test_two) 197 | return [m1, m2, m3, m4] 198 | 199 | 200 | class DummyException(Exception): 201 | pass 202 | 203 | 204 | class DummyTargetRaised(DummyException): 205 | pass 206 | 207 | 208 | class DummyTargetResult(DummyException): 209 | pass 210 | 211 | 212 | class DummyDataManager: 213 | def __init__(self, key, target=None, args=(), kwargs={}): 214 | self.key = key 215 | self.target = target 216 | self.args = args 217 | self.kwargs = kwargs 218 | 219 | def abort(self, trans): 220 | pass 221 | 222 | def tpc_begin(self, trans): 223 | pass 224 | 225 | def commit(self, trans): 226 | pass 227 | 228 | def tpc_vote(self, trans): 229 | if self.target is not None: 230 | try: 231 | result = self.target(*self.args, **self.kwargs) 232 | except Exception as e: 233 | raise DummyTargetRaised(e) 234 | raise DummyTargetResult(result) 235 | else: 236 | raise DummyException("DummyDataManager cannot commit") 237 | 238 | def tpc_finish(self, trans): 239 | pass 240 | 241 | def tpc_abort(self, trans): 242 | pass 243 | 244 | def sortKey(self): 245 | return self.key 246 | 247 | 248 | class ZopeSQLAlchemyTests(unittest.TestCase): 249 | def setUp(self): 250 | self.mappers = setup_mappers() 251 | metadata.drop_all(engine) 252 | metadata.create_all(engine) 253 | # a connection which bypasses the session/transaction machinery 254 | self.conn = engine.connect() 255 | 256 | def tearDown(self): 257 | transaction.abort() 258 | metadata.drop_all(engine) 259 | orm.clear_mappers() 260 | self.conn.close() 261 | 262 | def testMarkUnknownSession(self): 263 | import zope.sqlalchemy.datamanager 264 | 265 | DummyDataManager(key="dummy.first") 266 | session = Session() 267 | mark_changed(session) 268 | self.assertTrue(session in zope.sqlalchemy.datamanager._SESSION_STATE) 269 | 270 | def testAbortBeforeCommit(self): 271 | # Simulate what happens in a conflict error 272 | DummyDataManager(key="dummy.first") 273 | session = Session() 274 | conn = session.connection() 275 | mark_changed(session) 276 | try: 277 | # Thus we could fail in commit 278 | transaction.commit() 279 | except: # noqa: E722 do not use bare 'except' 280 | # But abort must succeed (and rollback the base connection) 281 | transaction.abort() 282 | pass 283 | # Or the next transaction will not be able to start! 284 | transaction.begin() 285 | session = Session() 286 | conn = session.connection() 287 | conn.execute(sql.text("SELECT 1 FROM test_users")) 288 | mark_changed(session) 289 | transaction.commit() 290 | 291 | def testAbortAfterCommit(self): 292 | # This is a regression test which used to wedge the transaction 293 | # machinery when using PostgreSQL (and perhaps other) connections. 294 | # Basically, if a commit failed, there was no way to abort the 295 | # transaction. Leaving the transaction wedged. 296 | transaction.begin() 297 | session = Session() 298 | conn = session.connection() 299 | # At least PostgresSQL requires a rollback after invalid SQL is 300 | # executed 301 | self.assertRaises(Exception, conn.execute, "BAD SQL SYNTAX") 302 | mark_changed(session) 303 | try: 304 | # Thus we could fail in commit 305 | transaction.commit() 306 | except: # noqa: E722 do not use bare 'except' 307 | # But abort must succed (and actually rollback the base connection) 308 | transaction.abort() 309 | pass 310 | # Or the next transaction will not be able to start! 311 | transaction.begin() 312 | session = Session() 313 | conn = session.connection() 314 | conn.execute(sql.text("SELECT 1 FROM test_users")) 315 | mark_changed(session) 316 | transaction.commit() 317 | 318 | def testSimplePopulation(self): 319 | session = Session() 320 | query = session.query(User) 321 | rows = query.all() 322 | self.assertEqual(len(rows), 0) 323 | 324 | session.add(User(id=1, firstname="udo", lastname="juergens")) 325 | session.add(User(id=2, firstname="heino", lastname="n/a")) 326 | session.flush() 327 | 328 | rows = query.order_by(User.id).all() 329 | self.assertEqual(len(rows), 2) 330 | row1 = rows[0] 331 | d = row1.asDict() 332 | self.assertEqual( 333 | d, {"firstname": "udo", "lastname": "juergens", "id": 1}) 334 | 335 | # bypass the session machinery 336 | if SA_GE_20: 337 | stmt = sql.select(*test_users.columns).order_by("id") 338 | else: 339 | stmt = sql.select(test_users.columns).order_by("id") 340 | 341 | conn = session.connection() 342 | results = conn.execute(stmt) 343 | self.assertEqual( 344 | results.fetchall(), [(1, "udo", "juergens"), (2, "heino", "n/a")] 345 | ) 346 | 347 | def testRelations(self): 348 | session = Session() 349 | session.add(User(id=1, firstname="foo", lastname="bar")) 350 | 351 | user = session.query(User).filter_by(firstname="foo")[0] 352 | user.skills.append(Skill(id=1, name="Zope")) 353 | session.flush() 354 | 355 | def testTransactionJoining(self): 356 | transaction.abort() # clean slate 357 | t = transaction.get() 358 | self.assertFalse( 359 | [r for r in t._resources if isinstance(r, tx.SessionDataManager)], 360 | "Joined transaction too early", 361 | ) 362 | session = Session() 363 | session.add(User(id=1, firstname="udo", lastname="juergens")) 364 | t = transaction.get() 365 | # Expect this to fail with SQLAlchemy 0.4 366 | self.assertTrue( 367 | [r for r in t._resources if isinstance(r, tx.SessionDataManager)], 368 | "Not joined transaction", 369 | ) 370 | 371 | def testTransactionJoiningUsingRegister(self): 372 | transaction.abort() # clean slate 373 | t = transaction.get() 374 | self.assertFalse( 375 | [r for r in t._resources if isinstance(r, tx.SessionDataManager)], 376 | "Joined transaction too early", 377 | ) 378 | session = EventSession() 379 | session.add(User(id=1, firstname="udo", lastname="juergens")) 380 | t = transaction.get() 381 | self.assertTrue( 382 | [r for r in t._resources if isinstance(r, tx.SessionDataManager)], 383 | "Not joined transaction", 384 | ) 385 | 386 | def testSavepoint(self): 387 | use_savepoint = engine.url.drivername not in tx.NO_SAVEPOINT_SUPPORT 388 | t = transaction.get() 389 | session = Session() 390 | query = session.query(User) 391 | self.assertFalse(query.all(), "Users table should be empty") 392 | 393 | t.savepoint(optimistic=True) # this should always work 394 | 395 | if not use_savepoint: 396 | self.assertRaises(TypeError, t.savepoint) 397 | return # sqlite databases do not support savepoints 398 | 399 | s1 = t.savepoint() 400 | session.add(User(id=1, firstname="udo", lastname="juergens")) 401 | session.flush() 402 | self.assertTrue(len(query.all()) == 1, 403 | "Users table should have one row") 404 | 405 | s2 = t.savepoint() 406 | session.add(User(id=2, firstname="heino", lastname="n/a")) 407 | session.flush() 408 | self.assertTrue(len(query.all()) == 2, 409 | "Users table should have two rows") 410 | 411 | s2.rollback() 412 | self.assertTrue(len(query.all()) == 1, 413 | "Users table should have one row") 414 | 415 | s1.rollback() 416 | self.assertFalse(query.all(), "Users table should be empty") 417 | 418 | def testRollbackAttributes(self): 419 | use_savepoint = engine.url.drivername not in tx.NO_SAVEPOINT_SUPPORT 420 | if not use_savepoint: 421 | self.skipTest('No savepoint support') 422 | 423 | t = transaction.get() 424 | session = Session() 425 | query = session.query(User) 426 | self.assertFalse(query.all(), "Users table should be empty") 427 | 428 | t.savepoint() 429 | user = User(id=1, firstname="udo", lastname="juergens") 430 | session.add(user) 431 | session.flush() 432 | 433 | s2 = t.savepoint() 434 | user.firstname = "heino" 435 | session.flush() 436 | s2.rollback() 437 | self.assertEqual( 438 | user.firstname, 439 | "udo", 440 | "User firstname attribute should have been rolled back", 441 | ) 442 | 443 | def testCommit(self): 444 | session = Session() 445 | 446 | query = session.query(User) 447 | rows = query.all() 448 | self.assertEqual(len(rows), 0) 449 | 450 | transaction.commit() # test a none modifying transaction works 451 | 452 | session = Session() 453 | query = session.query(User) 454 | 455 | session.add(User(id=1, firstname="udo", lastname="juergens")) 456 | session.add(User(id=2, firstname="heino", lastname="n/a")) 457 | session.flush() 458 | 459 | rows = query.order_by(User.id).all() 460 | self.assertEqual(len(rows), 2) 461 | 462 | transaction.abort() # test that the abort really aborts 463 | session = Session() 464 | query = session.query(User) 465 | rows = query.order_by(User.id).all() 466 | self.assertEqual(len(rows), 0) 467 | 468 | session.add(User(id=1, firstname="udo", lastname="juergens")) 469 | session.add(User(id=2, firstname="heino", lastname="n/a")) 470 | session.flush() 471 | rows = query.order_by(User.id).all() 472 | row1 = rows[0] 473 | d = row1.asDict() 474 | self.assertEqual( 475 | d, {"firstname": "udo", "lastname": "juergens", "id": 1}) 476 | 477 | transaction.commit() 478 | 479 | rows = query.order_by(User.id).all() 480 | self.assertEqual(len(rows), 2) 481 | row1 = rows[0] 482 | d = row1.asDict() 483 | self.assertEqual( 484 | d, {"firstname": "udo", "lastname": "juergens", "id": 1}) 485 | 486 | # bypass the session (and transaction) machinery 487 | with self.conn.begin(): 488 | results = self.conn.execute(test_users.select()) 489 | self.assertEqual(len(results.fetchall()), 2) 490 | 491 | def testCommitWithSavepoint(self): 492 | if engine.url.drivername in tx.NO_SAVEPOINT_SUPPORT: 493 | self.skipTest('No savepoint support') 494 | session = Session() 495 | session.add(User(id=1, firstname="udo", lastname="juergens")) 496 | session.add(User(id=2, firstname="heino", lastname="n/a")) 497 | session.flush() 498 | transaction.commit() 499 | 500 | session = Session() 501 | query = session.query(User) 502 | # lets just test that savepoints don't affect commits 503 | t = transaction.get() 504 | rows = query.order_by(User.id).all() 505 | 506 | t.savepoint() 507 | session.delete(rows[1]) 508 | session.flush() 509 | transaction.commit() 510 | 511 | # bypass the session machinery 512 | with self.conn.begin(): 513 | results = self.conn.execute(test_users.select()) 514 | self.assertEqual(len(results.fetchall()), 1) 515 | 516 | def testSessionSavepointCommitAllowed(self): 517 | # Existing code might use nested transactions 518 | if engine.url.drivername in tx.NO_SAVEPOINT_SUPPORT: 519 | self.skipTest('No save point support') 520 | session = Session() 521 | session.add(User(id=1, firstname="udo", lastname="juergens")) 522 | savepoint = session.begin_nested() 523 | session.add(User(id=2, firstname="heino", lastname="n/a")) 524 | savepoint.commit() 525 | transaction.commit() 526 | 527 | def testSessionCommitDisallowed(self): 528 | session = Session() 529 | session.add(User(id=1, firstname="udo", lastname="juergens")) 530 | self.assertRaises(AssertionError, session.commit) 531 | 532 | def testTwoPhase(self): 533 | session = Session() 534 | if not session.twophase: 535 | self.skipTest('No two phase transaction support') 536 | session.add(User(id=1, firstname="udo", lastname="juergens")) 537 | session.add(User(id=2, firstname="heino", lastname="n/a")) 538 | session.flush() 539 | transaction.commit() 540 | 541 | # Test that we clean up after a tpc_abort 542 | t = transaction.get() 543 | 544 | def target(): 545 | return engine.connect().recover_twophase() 546 | 547 | dummy = DummyDataManager(key="~~~dummy.last", target=target) 548 | t.join(dummy) 549 | session = Session() 550 | query = session.query(User) 551 | rows = query.all() 552 | session.delete(rows[0]) 553 | session.flush() 554 | result = None 555 | try: 556 | t.commit() 557 | except DummyTargetResult as e: 558 | result = e.args[0] 559 | except DummyTargetRaised as e: 560 | raise e.args[0] 561 | 562 | self.assertEqual( 563 | len(result), 564 | 1, 565 | "Should have been one prepared transaction when dummy aborted", 566 | ) 567 | 568 | transaction.begin() 569 | 570 | self.assertEqual( 571 | len(engine.connect().recover_twophase()), 572 | 0, 573 | "Test no outstanding prepared transactions", 574 | ) 575 | 576 | def testThread(self): 577 | transaction.abort() 578 | global thread_error 579 | thread_error = None 580 | 581 | def target(): 582 | try: 583 | session = Session() 584 | metadata.drop_all(engine) 585 | metadata.create_all(engine) 586 | 587 | query = session.query(User) 588 | rows = query.all() 589 | self.assertEqual(len(rows), 0) 590 | 591 | session.add(User(id=1, firstname="udo", lastname="juergens")) 592 | session.add(User(id=2, firstname="heino", lastname="n/a")) 593 | session.flush() 594 | 595 | rows = query.order_by(User.id).all() 596 | self.assertEqual(len(rows), 2) 597 | row1 = rows[0] 598 | d = row1.asDict() 599 | self.assertEqual( 600 | d, {"firstname": "udo", "lastname": "juergens", "id": 1} 601 | ) 602 | except Exception as err: 603 | global thread_error 604 | thread_error = err 605 | transaction.abort() 606 | 607 | thread = threading.Thread(target=target) 608 | thread.start() 609 | thread.join() 610 | if thread_error is not None: 611 | raise thread_error # reraise in current thread 612 | 613 | def testBulkDelete(self): 614 | session = Session() 615 | session.add(User(id=1, firstname="udo", lastname="juergens")) 616 | session.add(User(id=2, firstname="heino", lastname="n/a")) 617 | transaction.commit() 618 | session = Session() 619 | session.query(User).delete() 620 | transaction.commit() 621 | with self.conn.begin(): 622 | results = self.conn.execute(test_users.select()) 623 | self.assertEqual(len(results.fetchall()), 0) 624 | 625 | def testBulkUpdate(self): 626 | session = Session() 627 | session.add(User(id=1, firstname="udo", lastname="juergens")) 628 | session.add(User(id=2, firstname="heino", lastname="n/a")) 629 | transaction.commit() 630 | session = Session() 631 | session.query(User).update(dict(lastname="smith")) 632 | transaction.commit() 633 | with self.conn.begin(): 634 | results = self.conn.execute( 635 | test_users.select().where(test_users.c.lastname == "smith") 636 | ) 637 | self.assertEqual(len(results.fetchall()), 2) 638 | 639 | def testBulkDeleteUsingRegister(self): 640 | session = EventSession() 641 | session.add(User(id=1, firstname="udo", lastname="juergens")) 642 | session.add(User(id=2, firstname="heino", lastname="n/a")) 643 | transaction.commit() 644 | session = EventSession() 645 | session.query(User).delete() 646 | transaction.commit() 647 | with self.conn.begin(): 648 | results = self.conn.execute(test_users.select()) 649 | self.assertEqual(len(results.fetchall()), 0) 650 | 651 | def testBulkUpdateUsingRegister(self): 652 | session = EventSession() 653 | session.add(User(id=1, firstname="udo", lastname="juergens")) 654 | session.add(User(id=2, firstname="heino", lastname="n/a")) 655 | transaction.commit() 656 | session = EventSession() 657 | session.query(User).update(dict(lastname="smith")) 658 | transaction.commit() 659 | with self.conn.begin(): 660 | results = self.conn.execute( 661 | test_users.select().where(test_users.c.lastname == "smith") 662 | ) 663 | self.assertEqual(len(results.fetchall()), 2) 664 | 665 | def testFailedJoin(self): 666 | # When a join is issued while the transaction is in COMMITFAILED, the 667 | # session is never closed and the session id stays in _SESSION_STATE, 668 | # which means the session won't be joined in the future either. This 669 | # causes the session to stay open forever, potentially accumulating 670 | # data, but never issuing a commit. 671 | dummy = DummyDataManager(key="dummy.first") 672 | transaction.get().join(dummy) 673 | try: 674 | transaction.commit() 675 | except DummyException: 676 | # Commit raised an error, we are now in COMMITFAILED 677 | pass 678 | self.assertEqual(transaction.get().status, ZopeStatus.COMMITFAILED) 679 | 680 | session = Session() 681 | # try to interact with the session while the transaction is still 682 | # in COMMITFAILED 683 | self.assertRaises(TransactionFailedError, session.query(User).all) 684 | transaction.abort() 685 | 686 | # start a new transaction everything should be ok now 687 | transaction.begin() 688 | session = Session() 689 | self.assertEqual([], session.query(User).all()) 690 | session.add(User(id=1, firstname="udo", lastname="juergens")) 691 | 692 | # abort transaction, session should be closed without commit 693 | transaction.abort() 694 | self.assertEqual([], session.query(User).all()) 695 | 696 | def testKeepSession(self): 697 | session = KeepSession() 698 | 699 | try: 700 | with transaction.manager: 701 | session.add(User(id=1, firstname="foo", lastname="bar")) 702 | 703 | if SA_GE_20: 704 | user = session.get(User, 1) 705 | else: 706 | user = session.query(User).get(1) 707 | 708 | # if the keep_session works correctly, this transaction will not 709 | # close the session after commit 710 | with transaction.manager: 711 | user.firstname = "super" 712 | session.flush() 713 | 714 | # make sure the session is still attached to user 715 | self.assertEqual(user.firstname, "super") 716 | 717 | finally: 718 | # KeepSession does not rollback on transaction abort 719 | session.rollback() 720 | 721 | def testExpireAll(self): 722 | session = Session() 723 | session.add(User(id=1, firstname="udo", lastname="juergens")) 724 | transaction.commit() 725 | 726 | session = Session() 727 | if SA_GE_20: 728 | instance = session.get(User, 1) 729 | else: 730 | instance = session.query(User).get(1) 731 | transaction.commit() # No work, session.close() 732 | 733 | self.assertEqual(sa.inspect(instance).expired, True) 734 | 735 | 736 | class RetryTests(unittest.TestCase): 737 | def setUp(self): 738 | self.mappers = setup_mappers() 739 | metadata.drop_all(engine) 740 | metadata.create_all(engine) 741 | self.tm1 = transaction.TransactionManager() 742 | self.tm2 = transaction.TransactionManager() 743 | # With psycopg2 you might supply isolation_level='SERIALIZABLE' here, 744 | # unfortunately that is not supported by cx_Oracle. 745 | self.e1 = sa.create_engine(TEST_DSN) 746 | self.e2 = sa.create_engine(TEST_DSN) 747 | self.s1 = orm.sessionmaker(bind=self.e1, twophase=TEST_TWOPHASE)() 748 | tx.register(self.s1, transaction_manager=self.tm1) 749 | self.s2 = orm.sessionmaker(bind=self.e2, twophase=TEST_TWOPHASE)() 750 | tx.register(self.s2, transaction_manager=self.tm2) 751 | self.tm1.begin() 752 | self.s1.add(User(id=1, firstname="udo", lastname="juergens")) 753 | self.tm1.commit() 754 | 755 | def tearDown(self): 756 | self.tm1.abort() 757 | self.tm2.abort() 758 | metadata.drop_all(engine) 759 | orm.clear_mappers() 760 | # ensure any open connections on the temporary engines get closed 761 | # if we don't do this we get a `ResourceWarning` in psycopg v3 762 | self.e1.dispose() 763 | self.e2.dispose() 764 | self.e1 = None 765 | self.e2 = None 766 | self.s1 = None 767 | self.s2 = None 768 | 769 | def testRetry(self): 770 | # sqlite is unable to run this test as the databse is locked 771 | tm1, tm2, s1, s2 = self.tm1, self.tm2, self.s1, self.s2 772 | # make sure we actually start a session. 773 | tm1.begin() 774 | self.assertTrue( 775 | len(s1.query(User).all()) == 1, "Users table should have one row" 776 | ) 777 | tm2.begin() 778 | self.assertTrue( 779 | len(s2.query(User).all()) == 1, "Users table should have one row" 780 | ) 781 | s1.query(User).delete() 782 | if SA_GE_20: 783 | user = s2.get(User, 1) 784 | else: 785 | user = s2.query(User).get(1) 786 | user.lastname = "smith" 787 | tm1.commit() 788 | raised = False 789 | try: 790 | s2.flush() 791 | except orm.exc.ConcurrentModificationError as e: 792 | # This error is thrown when the number of updated rows is not as 793 | # expected 794 | raised = True 795 | self.assertTrue(tm2._retryable(type(e), e), 796 | "Error should be retryable") 797 | self.assertTrue(raised, "Did not raise expected error") 798 | 799 | def testRetryThread(self): 800 | tm1, tm2, s1, s2 = self.tm1, self.tm2, self.s1, self.s2 801 | # make sure we actually start a session. 802 | tm1.begin() 803 | self.assertTrue( 804 | len(s1.query(User).all()) == 1, "Users table should have one row" 805 | ) 806 | tm2.begin() 807 | s2.connection().execute(sql.text( 808 | "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" 809 | )) 810 | self.assertTrue( 811 | len(s2.query(User).all()) == 1, "Users table should have one row" 812 | ) 813 | s1.query(User).delete() 814 | raised = False 815 | 816 | def target(): 817 | time.sleep(0.2) 818 | tm1.commit() 819 | 820 | thread = threading.Thread(target=target) 821 | thread.start() 822 | try: 823 | if SA_GE_20: 824 | s2.query(User).with_for_update().filter(User.id == 1).one() 825 | else: 826 | s2.query(User).with_for_update().get(1) 827 | except exc.DBAPIError as e: 828 | # This error wraps the underlying DBAPI module error, some of which 829 | # are retryable 830 | raised = True 831 | retryable = tm2._retryable(type(e), e) 832 | self.assertTrue(retryable, "Error should be retryable") 833 | self.assertTrue(raised, "Did not raise expected error") 834 | thread.join() # well, we must have joined by now 835 | 836 | 837 | class MultipleEngineTests(unittest.TestCase): 838 | def setUp(self): 839 | if SA_GE_20: 840 | self.skipTest( 841 | 'Bound metadata is not supported in SQLAlchemy 2.0' 842 | ) 843 | 844 | self.mappers = setup_mappers() 845 | bound_metadata1.drop_all() 846 | bound_metadata1.create_all() 847 | bound_metadata2.drop_all() 848 | bound_metadata2.create_all() 849 | 850 | def tearDown(self): 851 | transaction.abort() 852 | bound_metadata1.drop_all() 853 | bound_metadata2.drop_all() 854 | orm.clear_mappers() 855 | 856 | def testTwoEngines(self): 857 | session = UnboundSession() 858 | session.add(TestOne(id=1)) 859 | session.add(TestTwo(id=2)) 860 | session.flush() 861 | transaction.commit() 862 | session = UnboundSession() 863 | rows = session.query(TestOne).all() 864 | self.assertEqual(len(rows), 1) 865 | rows = session.query(TestTwo).all() 866 | self.assertEqual(len(rows), 1) 867 | 868 | 869 | def tearDownReadMe(test): 870 | Base = test.globs["Base"] 871 | engine = test.globs["engine"] 872 | Base.metadata.drop_all(engine) 873 | 874 | 875 | def test_suite(): 876 | import doctest 877 | from unittest import TestSuite 878 | from unittest import makeSuite 879 | 880 | optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS 881 | suite = TestSuite() 882 | suite.addTest(makeSuite(ZopeSQLAlchemyTests)) 883 | suite.addTest(makeSuite(MultipleEngineTests)) 884 | if TEST_DSN.startswith("postgres") or TEST_DSN.startswith("oracle"): 885 | suite.addTest(makeSuite(RetryTests)) 886 | 887 | # examples in docs are only correct for SQLAlchemy >=1.4 888 | if parse_version(sqlalchemy_version) >= parse_version('1.4.0'): 889 | suite.addTest( 890 | doctest.DocFileSuite( 891 | "README.rst", 892 | optionflags=optionflags, 893 | tearDown=tearDownReadMe, 894 | globs={"TEST_DSN": TEST_DSN, "TEST_TWOPHASE": TEST_TWOPHASE}, 895 | ) 896 | ) 897 | return suite 898 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Generated from: 2 | # https://github.com/zopefoundation/meta/tree/master/config/zope-product 3 | [tox] 4 | minversion = 3.18 5 | envlist = 6 | lint 7 | py37 8 | py38 9 | py39 10 | py310 11 | py311 12 | coverage 13 | py{37,38,39}-sqlalchemy11 14 | py{37,38,39,310}-sqlalchemy{12,13} 15 | py{37,38,39,310,311}-sqlalchemy{14,20} 16 | 17 | [testenv] 18 | skip_install = true 19 | deps = 20 | zc.buildout >= 3.0.1 21 | wheel > 0.37 22 | sqlalchemy11: SQLAlchemy==1.1.* 23 | sqlalchemy12: SQLAlchemy==1.2.* 24 | sqlalchemy13: SQLAlchemy==1.3.* 25 | sqlalchemy14: SQLAlchemy==1.4.* 26 | sqlalchemy20: SQLAlchemy==2.0.* 27 | commands_pre = 28 | !sqlalchemy20: sh -c 'if [ '{env:CI:false}' = 'true' ]; then {envbindir}/buildout -nc {toxinidir}/github_actions.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi' 29 | !sqlalchemy20: sh -c 'if [ '{env:CI:false}' != 'true' ]; then {envbindir}/buildout -nc {toxinidir}/postgres.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi' 30 | sqlalchemy20: sh -c 'if [ '{env:CI:false}' = 'true' ]; then {envbindir}/buildout -nc {toxinidir}/github_actions20.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi' 31 | sqlalchemy20: sh -c 'if [ '{env:CI:false}' != 'true' ]; then {envbindir}/buildout -nc {toxinidir}/postgres20.cfg buildout:directory={envdir} buildout:develop={toxinidir} ; fi' 32 | commands = 33 | {envbindir}/test {posargs:-cv} 34 | sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg {posargs:-cv} ; fi' 35 | sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg2 {posargs:-cv} ; fi' 36 | sqlalchemy20: sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg3 {posargs:-cv} ; fi' 37 | sqlalchemy20: sh -c 'if [ '{env:TEST_PG:{env:CI:false}}' = 'true' ]; then {envbindir}/testpg32 {posargs:-cv} ; fi' 38 | passenv = 39 | CI 40 | TEST_PG 41 | allowlist_externals = 42 | sh 43 | 44 | [testenv:lint] 45 | basepython = python3 46 | commands_pre = 47 | mkdir -p {toxinidir}/parts/flake8 48 | allowlist_externals = 49 | mkdir 50 | commands = 51 | isort --check-only --diff {toxinidir}/src {toxinidir}/setup.py 52 | flake8 {toxinidir}/src {toxinidir}/setup.py 53 | check-manifest 54 | check-python-versions 55 | deps = 56 | check-manifest 57 | check-python-versions 58 | flake8 59 | isort 60 | # Useful flake8 plugins that are Python and Plone specific: 61 | flake8-coding 62 | flake8-debugger 63 | mccabe 64 | 65 | [testenv:isort-apply] 66 | basepython = python3 67 | commands_pre = 68 | deps = 69 | isort 70 | commands = 71 | isort {toxinidir}/src {toxinidir}/setup.py [] 72 | 73 | [testenv:coverage] 74 | basepython = python3 75 | skip_install = true 76 | allowlist_externals = 77 | {[testenv]allowlist_externals} 78 | mkdir 79 | deps = 80 | {[testenv]deps} 81 | coverage 82 | commands = 83 | mkdir -p {toxinidir}/parts/htmlcov 84 | coverage run {envdir}/bin/test {posargs:-cv} 85 | coverage html 86 | coverage report -m --fail-under=65 87 | 88 | [coverage:run] 89 | branch = True 90 | source = zope.sqlalchemy 91 | 92 | [coverage:report] 93 | precision = 2 94 | exclude_lines = 95 | pragma: no cover 96 | pragma: nocover 97 | except ImportError: 98 | raise NotImplementedError 99 | if __name__ == '__main__': 100 | self.fail 101 | raise AssertionError 102 | 103 | [coverage:html] 104 | directory = parts/htmlcov 105 | --------------------------------------------------------------------------------