mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-07-24 01:44:31 +02:00
Compare commits
2 commits
main
...
claude/sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfcbb4731 |
||
|
|
887c6543ee |
19 changed files with 390 additions and 732 deletions
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
|
@ -40,7 +40,9 @@ jobs:
|
|||
pytest -v
|
||||
- name: Run autocommit tests just on 3.14/Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.14'
|
||||
run: pytest --sqlite-autocommit
|
||||
run: |
|
||||
pytest --sqlite-autocommit
|
||||
pytest --sqlite-autocommit-false
|
||||
- name: run mypy
|
||||
run: mypy sqlite_utils tests
|
||||
- name: run flake8
|
||||
|
|
|
|||
|
|
@ -4,25 +4,16 @@
|
|||
Changelog
|
||||
===========
|
||||
|
||||
.. _v4_1_1:
|
||||
.. _v_unreleased:
|
||||
|
||||
4.1.1 (2026-07-12)
|
||||
------------------
|
||||
|
||||
- ``table.transform()`` now raises a ``TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT``. The pragma cannot be changed inside a transaction, so previously dropping the old table as part of the transform could fire those actions and silently delete or modify referencing rows. See :ref:`python_api_transform_foreign_keys_transactions` for details and workarounds. (:issue:`794`)
|
||||
- The :ref:`CLI <cli>` and :ref:`Python API <python_api>` documentation now cross-reference each other: CLI sections link to the equivalent Python API functionality and Python API sections link back to the corresponding CLI command. (:issue:`791`)
|
||||
.. _v4_1:
|
||||
|
||||
4.1 (2026-07-11)
|
||||
----------------
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
- ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo "select * from dogs" | sqlite-utils query dogs.db -``. (:issue:`765`)
|
||||
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept a ``--code`` option for :ref:`providing a block of Python code <cli_insert_code>` (or a path to a ``.py`` file) that defines a ``rows()`` function or ``rows`` iterable of rows to insert, as an alternative to importing from a file. (:issue:`684`)
|
||||
- ``sqlite-utils insert`` and ``sqlite-utils upsert`` now accept ``--type column-name type`` to :ref:`override the type automatically chosen when the table is created <cli_insert_csv_tsv_column_types>`. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as ``TEXT`` to preserve leading zeros. (:issue:`131`)
|
||||
- New ``table.drop_index(name)`` method and ``sqlite-utils drop-index`` command for dropping an index by name. Both accept ``ignore=True``/``--ignore`` to ignore a missing index. (:issue:`626`)
|
||||
- ``sqlite-utils query`` can now read the SQL query from standard input by passing ``-`` in place of the query, for example ``echo "select * from dogs" | sqlite-utils query dogs.db -``. (:issue:`765`)
|
||||
- ``sqlite-utils upsert`` can now infer the primary key of an existing table, so ``--pk`` can be omitted when upserting into a table that already has a primary key.
|
||||
- ``table.transform()`` and ``table.transform_sql()`` now accept ``strict=True`` or ``strict=False`` to change a table's `SQLite strict mode <https://www.sqlite.org/stricttables.html>`__. Omitting the option preserves the existing mode. (:issue:`787`)
|
||||
- The ``sqlite-utils transform`` command now accepts ``--strict`` and ``--no-strict`` to change a table's strict mode. (:issue:`787`)
|
||||
- ``Database()`` now accepts connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` and ``autocommit=False`` options, and adapts its transaction handling so every connection mode behaves identically. Writes still commit automatically unless a transaction opened with ``db.begin()`` or ``db.atomic()`` is open. On ``autocommit=False`` connections - where the driver holds an implicit transaction open at all times - row-returning statements executed outside of a transaction are fetched eagerly so their read transactions do not hold locks that block other connections, and ``PRAGMA``/``VACUUM`` statements run in temporary driver-level autocommit mode. A new ``db.in_transaction`` property reports whether a ``begin()``/``atomic()`` transaction is open, which ``conn.in_transaction`` cannot answer for ``autocommit=False`` connections. See :ref:`python_api_transactions_modes`.
|
||||
|
||||
.. _v4_0:
|
||||
|
||||
|
|
|
|||
|
|
@ -508,8 +508,6 @@ See :ref:`cli_transform_table`.
|
|||
Add a foreign key constraint from a column to
|
||||
another table with another column
|
||||
--drop-foreign-key TEXT Drop foreign key constraint for this column
|
||||
--strict / --no-strict Enable or disable STRICT mode (default:
|
||||
preserve current mode)
|
||||
--sql Output SQL without executing it
|
||||
--load-extension TEXT Path to SQLite extension, with optional
|
||||
:entrypoint
|
||||
|
|
|
|||
106
docs/cli.rst
106
docs/cli.rst
|
|
@ -361,7 +361,7 @@ To return the first column of each result as raw data, separated by newlines, us
|
|||
Using named parameters
|
||||
----------------------
|
||||
|
||||
You can pass named parameters to the query using ``-p name value``:
|
||||
You can pass named parameters to the query using ``-p``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
|
@ -424,9 +424,6 @@ The ``--functions`` option can be used multiple times to load functions from mul
|
|||
from urllib.parse import urlparse
|
||||
return urlparse(url).path'
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.register_function() <python_api_register_function>`
|
||||
|
||||
.. _cli_query_extensions:
|
||||
|
||||
SQLite extensions
|
||||
|
|
@ -1027,9 +1024,6 @@ To show more than 10 common values, use ``--common-limit 20``. To skip the most
|
|||
|
||||
sqlite-utils analyze-tables github.db tags --common-limit 20 --no-least
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.analyze_column() <python_api_analyze_column>` CLI reference: :ref:`sqlite-utils analyze-tables <cli_ref_analyze_tables>`
|
||||
|
||||
.. _cli_analyze_tables_save:
|
||||
|
||||
Saving the analyzed table details
|
||||
|
|
@ -1197,9 +1191,6 @@ You can delete all the existing rows in the table before inserting the new recor
|
|||
|
||||
You can add the ``--analyze`` option to run ``ANALYZE`` against the table after the rows have been inserted.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert_all() <python_api_bulk_inserts>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
|
||||
|
||||
.. _cli_inserting_data_binary:
|
||||
|
||||
Inserting binary data
|
||||
|
|
@ -1624,9 +1615,6 @@ To replace a dog with in ID of 2 with a new record, run the following:
|
|||
echo '{"id": 2, "name": "Pancakes", "age": 3}' | \
|
||||
sqlite-utils insert dogs.db dogs - --pk=id --replace
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert(..., replace=True) <python_api_insert_replace>` CLI reference: :ref:`sqlite-utils insert <cli_ref_insert>`
|
||||
|
||||
.. _cli_upsert:
|
||||
|
||||
Upserting data
|
||||
|
|
@ -1653,9 +1641,6 @@ The command will fail if you reference columns that do not exist on the table. T
|
|||
``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
|
||||
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.upsert() <python_api_upsert>` CLI reference: :ref:`sqlite-utils upsert <cli_ref_upsert>`
|
||||
|
||||
.. _cli_bulk:
|
||||
|
||||
Executing SQL in bulk
|
||||
|
|
@ -1857,9 +1842,6 @@ You can include named parameters in your where clause and populate them using on
|
|||
|
||||
The ``--dry-run`` option will output a preview of the conversion against the first ten rows, without modifying the database.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.convert() <python_api_convert>` CLI reference: :ref:`sqlite-utils convert <cli_ref_convert>`
|
||||
|
||||
.. _cli_convert_import:
|
||||
|
||||
Importing additional modules
|
||||
|
|
@ -2158,9 +2140,6 @@ If a table with the same name already exists, you will get an error. You can cho
|
|||
|
||||
You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create() <python_api_explicit_create>` CLI reference: :ref:`sqlite-utils create-table <cli_ref_create_table>`
|
||||
|
||||
.. _cli_renaming_tables:
|
||||
|
||||
Renaming a table
|
||||
|
|
@ -2174,9 +2153,6 @@ Yo ucan rename a table using the ``rename-table`` command:
|
|||
|
||||
Pass ``--ignore`` to ignore any errors caused by the table not existing, or the new name already being in use.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.rename_table() <python_api_rename_table>` CLI reference: :ref:`sqlite-utils rename-table <cli_ref_rename_table>`
|
||||
|
||||
.. _cli_duplicate_table:
|
||||
|
||||
Duplicating tables
|
||||
|
|
@ -2188,9 +2164,6 @@ The ``duplicate`` command duplicates a table - creating a new table with the sam
|
|||
|
||||
sqlite-utils duplicate books.db authors authors_copy
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.duplicate() <python_api_duplicate>` CLI reference: :ref:`sqlite-utils duplicate <cli_ref_duplicate>`
|
||||
|
||||
.. _cli_drop_table:
|
||||
|
||||
Dropping tables
|
||||
|
|
@ -2204,15 +2177,12 @@ You can drop a table using the ``drop-table`` command:
|
|||
|
||||
Use ``--ignore`` to ignore the error if the table does not exist.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-table <cli_ref_drop_table>`
|
||||
|
||||
.. _cli_transform_table:
|
||||
|
||||
Transforming tables
|
||||
===================
|
||||
|
||||
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. By default, the ``transform`` command preserves a table's ``STRICT`` mode.
|
||||
The ``transform`` command allows you to apply complex transformations to a table that cannot be implemented using a regular SQLite ``ALTER TABLE`` command. See :ref:`python_api_transform` for details of how this works. The ``transform`` command preserves a table's ``STRICT`` mode.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
|
@ -2258,12 +2228,6 @@ Every option for this table (with the exception of ``--pk-none``) can be specifi
|
|||
``--add-foreign-key column other_table other_column``
|
||||
Add a foreign key constraint to ``column`` pointing to ``other_table.other_column``.
|
||||
|
||||
``--strict``
|
||||
Convert the table to a `SQLite STRICT table <https://www.sqlite.org/stricttables.html>`__. The command fails if the available SQLite version does not support strict tables. If existing rows contain values that are incompatible with their declared column types the transformation fails and the original table is left unchanged.
|
||||
|
||||
``--no-strict``
|
||||
Convert a strict table back to a regular non-strict table.
|
||||
|
||||
If you want to see the SQL that will be executed to make the change without actually executing it, add the ``--sql`` flag. For example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
|
@ -2290,9 +2254,6 @@ If you want to see the SQL that will be executed to make the change without actu
|
|||
DROP TABLE "roadside_attractions";
|
||||
ALTER TABLE "roadside_attractions_new_4033a60276b9" RENAME TO "roadside_attractions";
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.transform() <python_api_transform>` CLI reference: :ref:`sqlite-utils transform <cli_ref_transform>`
|
||||
|
||||
.. _cli_transform_table_add_primary_key_to_rowid:
|
||||
|
||||
Adding a primary key to a rowid table
|
||||
|
|
@ -2470,9 +2431,6 @@ After running the above, the command ``sqlite-utils schema global.db`` reveals t
|
|||
CREATE UNIQUE INDEX "idx_countries_country_name"
|
||||
ON "countries" ("country", "name");
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.extract() <python_api_extract>` CLI reference: :ref:`sqlite-utils extract <cli_ref_extract>`
|
||||
|
||||
.. _cli_create_view:
|
||||
|
||||
Creating views
|
||||
|
|
@ -2494,9 +2452,6 @@ You can create a view using the ``create-view`` command:
|
|||
|
||||
Use ``--replace`` to replace an existing view of the same name, and ``--ignore`` to do nothing if a view already exists.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.create_view() <python_api_create_view>` CLI reference: :ref:`sqlite-utils create-view <cli_ref_create_view>`
|
||||
|
||||
.. _cli_drop_view:
|
||||
|
||||
Dropping views
|
||||
|
|
@ -2510,9 +2465,6 @@ You can drop a view using the ``drop-view`` command:
|
|||
|
||||
Use ``--ignore`` to ignore the error if the view does not exist.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`view.drop() <python_api_drop>` CLI reference: :ref:`sqlite-utils drop-view <cli_ref_drop_view>`
|
||||
|
||||
.. _cli_add_column:
|
||||
|
||||
Adding columns
|
||||
|
|
@ -2553,9 +2505,6 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``--no
|
|||
|
||||
sqlite-utils add-column mydb.db dogs friends_count integer --not-null-default 0
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_column() <python_api_add_column>` CLI reference: :ref:`sqlite-utils add-column <cli_ref_add_column>`
|
||||
|
||||
.. _cli_add_column_alter:
|
||||
|
||||
Adding columns automatically on insert/update
|
||||
|
|
@ -2567,9 +2516,6 @@ You can use the ``--alter`` option to automatically add new columns if the data
|
|||
|
||||
sqlite-utils insert dogs.db dogs new-dogs.json --pk=id --alter
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.insert(..., alter=True) <python_api_add_column_alter>`
|
||||
|
||||
.. _cli_add_foreign_key:
|
||||
|
||||
Adding foreign key constraints
|
||||
|
|
@ -2597,9 +2543,6 @@ Add ``--ignore`` to ignore an existing foreign key (as opposed to returning an e
|
|||
|
||||
See :ref:`python_api_add_foreign_key` in the Python API documentation for further details, including how the automatic table guessing mechanism works.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_foreign_key() <python_api_add_foreign_key>` CLI reference: :ref:`sqlite-utils add-foreign-key <cli_ref_add_foreign_key>`
|
||||
|
||||
.. _cli_add_foreign_keys:
|
||||
|
||||
Adding multiple foreign keys at once
|
||||
|
|
@ -2615,9 +2558,6 @@ Adding a foreign key requires a ``VACUUM``. On large databases this can be an ex
|
|||
|
||||
When you are using this command each foreign key needs to be defined in full, as four arguments - the table, column, other table and other column.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.add_foreign_keys() <python_api_add_foreign_keys>` CLI reference: :ref:`sqlite-utils add-foreign-keys <cli_ref_add_foreign_keys>`
|
||||
|
||||
.. _cli_index_foreign_keys:
|
||||
|
||||
Adding indexes for all foreign keys
|
||||
|
|
@ -2629,9 +2569,6 @@ If you want to ensure that every foreign key column in your database has a corre
|
|||
|
||||
sqlite-utils index-foreign-keys books.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.index_foreign_keys() <python_api_index_foreign_keys>` CLI reference: :ref:`sqlite-utils index-foreign-keys <cli_ref_index_foreign_keys>`
|
||||
|
||||
.. _cli_defaults_not_null:
|
||||
|
||||
Setting defaults and not null constraints
|
||||
|
|
@ -2647,9 +2584,6 @@ You can use the ``--not-null`` and ``--default`` options (to both ``insert`` and
|
|||
--default age 2 \
|
||||
--default score 5
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`not_null= and defaults= arguments <python_api_defaults_not_null>`
|
||||
|
||||
.. _cli_create_index:
|
||||
|
||||
Creating indexes
|
||||
|
|
@ -2681,9 +2615,6 @@ If your column names are already prefixed with a hyphen you'll need to manually
|
|||
|
||||
Add the ``--analyze`` option to run ``ANALYZE`` against the index after it has been created.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils create-index <cli_ref_create_index>`
|
||||
|
||||
.. _cli_drop_index:
|
||||
|
||||
Dropping indexes
|
||||
|
|
@ -2697,9 +2628,6 @@ You can drop an index from an existing table using the ``drop-index`` command:
|
|||
|
||||
Use ``--ignore`` to ignore the error if the index does not exist on that table.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.drop_index() <python_api_create_index>` CLI reference: :ref:`sqlite-utils drop-index <cli_ref_drop_index>`
|
||||
|
||||
.. _cli_fts:
|
||||
|
||||
Configuring full-text search
|
||||
|
|
@ -2753,9 +2681,6 @@ You can rebuild every FTS table by running ``rebuild-fts`` without passing any t
|
|||
|
||||
sqlite-utils rebuild-fts mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.enable_fts() <python_api_fts_enable>` CLI reference: :ref:`sqlite-utils enable-fts <cli_ref_enable_fts>`
|
||||
|
||||
.. _cli_search:
|
||||
|
||||
Executing searches
|
||||
|
|
@ -2812,9 +2737,6 @@ Use the ``--sql`` option to output the SQL that would be executed, rather than r
|
|||
order by
|
||||
"documents_fts".rank
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.search() <python_api_fts_search>` CLI reference: :ref:`sqlite-utils search <cli_ref_search>`
|
||||
|
||||
.. _cli_enable_counts:
|
||||
|
||||
Enabling cached counts
|
||||
|
|
@ -2838,9 +2760,6 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
|
|||
|
||||
sqlite-utils reset-counts mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.enable_counts() <python_api_cached_table_counts>` CLI reference: :ref:`sqlite-utils enable-counts <cli_ref_enable_counts>`
|
||||
|
||||
.. _cli_analyze:
|
||||
|
||||
Optimizing index usage with ANALYZE
|
||||
|
|
@ -2864,9 +2783,6 @@ You can run it against specific tables, or against specific named indexes, by pa
|
|||
|
||||
You can also run ``ANALYZE`` as part of another command using the ``--analyze`` option. This is supported by the ``create-index``, ``insert`` and ``upsert`` commands.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.analyze() <python_api_analyze>` CLI reference: :ref:`sqlite-utils analyze <cli_ref_analyze>`
|
||||
|
||||
.. _cli_vacuum:
|
||||
|
||||
Vacuum
|
||||
|
|
@ -2878,9 +2794,6 @@ You can run VACUUM to optimize your database like so:
|
|||
|
||||
sqlite-utils vacuum mydb.db
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.vacuum() <python_api_vacuum>` CLI reference: :ref:`sqlite-utils vacuum <cli_ref_vacuum>`
|
||||
|
||||
.. _cli_optimize:
|
||||
|
||||
Optimize
|
||||
|
|
@ -2904,9 +2817,6 @@ To optimize specific tables rather than every FTS table, pass those tables as ex
|
|||
|
||||
sqlite-utils optimize mydb.db table_1 table_2
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.optimize() <python_api_fts_optimize>` CLI reference: :ref:`sqlite-utils optimize <cli_ref_optimize>`
|
||||
|
||||
.. _cli_wal:
|
||||
|
||||
WAL mode
|
||||
|
|
@ -2926,9 +2836,6 @@ You can disable WAL mode using ``disable-wal``:
|
|||
|
||||
Both of these commands accept one or more database files as arguments.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.enable_wal() and db.disable_wal() <python_api_wal>` CLI reference: :ref:`sqlite-utils enable-wal <cli_ref_enable_wal>`
|
||||
|
||||
.. _cli_dump:
|
||||
|
||||
Dumping the database to SQL
|
||||
|
|
@ -2944,9 +2851,6 @@ The ``dump`` command outputs a SQL dump of the schema and full contents of the s
|
|||
...
|
||||
COMMIT;
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`db.iterdump() <python_api_itedump>` CLI reference: :ref:`sqlite-utils dump <cli_ref_dump>`
|
||||
|
||||
.. _cli_load_extension:
|
||||
|
||||
Loading SQLite extensions
|
||||
|
|
@ -2994,9 +2898,6 @@ Eight (case-insensitive) types are allowed:
|
|||
* GEOMETRYCOLLECTION
|
||||
* GEOMETRY
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.add_geometry_column() <python_api_gis_add_geometry_column>` CLI reference: :ref:`sqlite-utils add-geometry-column <cli_ref_add_geometry_column>`
|
||||
|
||||
.. _cli_spatialite_indexes:
|
||||
|
||||
Adding spatial indexes
|
||||
|
|
@ -3010,9 +2911,6 @@ Once you have a geometry column, you can speed up bounding box queries by adding
|
|||
|
||||
See this `SpatiaLite Cookbook recipe <http://www.gaia-gis.it/gaia-sins/spatialite-cookbook-5/cookbook_topics.03.html#topic_Wonderful_RTree_Spatial_Index>`__ for examples of how to use a spatial index.
|
||||
|
||||
.. note::
|
||||
In Python: :ref:`table.create_spatial_index() <python_api_gis_create_spatial_index>` CLI reference: :ref:`sqlite-utils create-spatial-index <cli_ref_create_spatial_index>`
|
||||
|
||||
.. _cli_install:
|
||||
|
||||
Installing packages
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ Instead of a file path you can pass in an existing SQLite connection:
|
|||
|
||||
db = Database(sqlite3.connect("my_database.db"))
|
||||
|
||||
The connection must use Python's default transaction handling. Connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options are rejected with a ``sqlite_utils.db.TransactionError`` - see :ref:`python_api_transactions_modes`.
|
||||
The connection can use Python's default transaction handling, or the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` modes - see :ref:`python_api_transactions_modes`.
|
||||
|
||||
If you want to create an in-memory database, you can do so like this:
|
||||
|
||||
|
|
@ -184,9 +184,6 @@ You can attach an additional database using the ``.attach()`` method, providing
|
|||
|
||||
You can reference tables in the attached database using the alias value you passed to ``db.attach(alias, filepath)`` as a prefix, for example the ``second.table_in_second`` reference in the SQL query above.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils --attach <cli_query_attach>`
|
||||
|
||||
.. _python_api_tracing:
|
||||
|
||||
Tracing queries
|
||||
|
|
@ -257,9 +254,6 @@ If a query returns more than one column with the same name - a join between two
|
|||
|
||||
A suffix that would collide with another column in the query is skipped - ``select 1 as id, 2 as id, 3 as id_2`` returns ``{'id': 1, 'id_3': 2, 'id_2': 3}``. The same renaming is applied by ``table.rows_where()`` and ``table.search()``.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils query <cli_query>`
|
||||
|
||||
.. _python_api_execute:
|
||||
|
||||
db.execute(sql, params)
|
||||
|
|
@ -326,15 +320,11 @@ Every method in this library that writes to the database - ``insert()``, ``upser
|
|||
|
||||
The same applies to raw SQL executed with :ref:`db.execute() <python_api_transactions_execute>` - a write statement is committed as soon as it has run.
|
||||
|
||||
Another way to think about this is that each sqlite-utils method call is its own unit of work. If several method calls must either all succeed or all fail, use ``db.atomic()`` to turn them into a single unit of work.
|
||||
|
||||
You never need to call ``commit()``, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:
|
||||
|
||||
1. You want to group several write operations together, so they either all succeed or all fail - use :ref:`db.atomic() <python_api_atomic>`.
|
||||
2. You are :ref:`managing a transaction yourself <python_api_transactions_manual>` with ``db.begin()``, in which case nothing is committed until you commit - the library will never commit a transaction you opened.
|
||||
|
||||
``with Database(...) as db:`` is not a transaction block. It manages the lifetime of the database connection and closes it on exit. Use ``with db.atomic():`` for a transaction.
|
||||
|
||||
.. _python_api_atomic:
|
||||
|
||||
Grouping changes with db.atomic()
|
||||
|
|
@ -350,27 +340,6 @@ Use ``db.atomic()`` to group multiple operations in a single transaction:
|
|||
|
||||
The transaction commits when the block exits. If an exception is raised, changes made inside the block will be rolled back.
|
||||
|
||||
This matters when several operations represent a single logical change. Without ``db.atomic()``, an earlier method call remains committed if a later one fails:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# These are two separate transactions
|
||||
db.table("accounts").update(1, {"balance": 90})
|
||||
db.table("accounts").update(2, {"balance": 110})
|
||||
|
||||
# These updates either both succeed or both fail
|
||||
with db.atomic():
|
||||
db.table("accounts").update(1, {"balance": 90})
|
||||
db.table("accounts").update(2, {"balance": 110})
|
||||
|
||||
Transactions can also improve performance. Calling ``insert()`` repeatedly outside ``db.atomic()`` creates and commits a separate transaction for every call. For bulk inserts, prefer :ref:`insert_all() <python_api_bulk_inserts>`. If you need to call several different methods in a loop, wrap the loop in ``db.atomic()``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with db.atomic():
|
||||
for row in rows:
|
||||
db.table("events").insert(row)
|
||||
|
||||
``db.atomic()`` can be nested. Nested blocks use SQLite savepoints, so an exception in an inner block can roll back to that savepoint without rolling back the entire outer transaction:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -399,8 +368,6 @@ Write statements executed with :ref:`db.execute() <python_api_execute>` follow t
|
|||
db.execute("insert into news (headline) values (?)", ["Dog wins award"])
|
||||
# Already committed
|
||||
|
||||
``db.execute()`` participates in sqlite-utils transaction handling. Calling ``db.conn.execute()`` directly bypasses that policy and leaves transaction handling to Python's underlying ``sqlite3.Connection``. Prefer ``db.execute()`` unless you deliberately need the lower-level API.
|
||||
|
||||
If a transaction is open - because the call happens inside a ``db.atomic()`` block, or after ``db.begin()`` - the statement becomes part of that transaction instead, and commits when the transaction commits:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -432,12 +399,9 @@ You can take full manual control using the ``db.begin()``, ``db.commit()`` and `
|
|||
|
||||
The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too.
|
||||
|
||||
Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction.
|
||||
|
||||
Some related safeguards to be aware of:
|
||||
Two related safeguards to be aware of:
|
||||
|
||||
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect.
|
||||
- ``table.transform()`` raises a ``sqlite_utils.db.TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`.
|
||||
- Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`.
|
||||
|
||||
.. _python_api_transactions_modes:
|
||||
|
|
@ -445,11 +409,19 @@ Some related safeguards to be aware of:
|
|||
Supported connection modes
|
||||
--------------------------
|
||||
|
||||
``db.atomic()`` and the automatic per-method transactions currently require a connection using Python's legacy transaction control mode (``sqlite3.LEGACY_TRANSACTION_CONTROL`` on Python 3.12 and later). Passing a connection created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options to ``Database()`` raises a ``sqlite_utils.db.TransactionError``.
|
||||
``db.atomic()`` and the automatic per-method transactions work with connections in all three of Python's transaction handling modes: the default mode, and the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` and ``autocommit=False`` modes.
|
||||
|
||||
Connections using ``autocommit=False`` are not supported because Python keeps a transaction open continuously. sqlite-utils uses ``Connection.in_transaction`` to distinguish its own transactions from transactions opened by its caller, and that distinction is not available in this mode.
|
||||
In the default mode and with ``autocommit=True`` the library manages transactions itself using explicit ``BEGIN``, ``COMMIT``, ``ROLLBACK`` and savepoint statements, which behave identically in both modes.
|
||||
|
||||
Connections using ``autocommit=True`` are also currently rejected because sqlite-utils has not formally exposed that as a supported configuration.
|
||||
With ``autocommit=False`` the ``sqlite3`` driver holds an implicit transaction open at all times, so the library adapts to preserve the same behavior:
|
||||
|
||||
- Writes made through the library still commit automatically, unless a transaction opened with ``db.begin()`` or ``db.atomic()`` is open. ``db.begin()`` claims the driver's implicit transaction rather than executing ``BEGIN``, which the driver would reject.
|
||||
- ``BEGIN``, ``COMMIT`` and ``ROLLBACK`` statements passed to ``db.execute()`` are routed through ``db.begin()``, ``db.commit()`` and ``db.rollback()``.
|
||||
- ``PRAGMA`` and ``VACUUM`` statements executed outside of a transaction run in temporary driver-level autocommit mode, because both are silently ignored - or refused - inside the driver's implicit transaction.
|
||||
- Row-returning statements executed with ``db.execute()`` or ``db.query()`` outside of a transaction fetch their results eagerly, then commit. A lazily-read cursor would hold the implicit read transaction - and its shared database lock, which blocks writes from other connections - open indefinitely. This means very large result sets are buffered in memory in this mode.
|
||||
- Statements executed directly on ``db.conn`` are not managed: the driver's own rules apply, and nothing is committed until you call ``db.conn.commit()`` yourself.
|
||||
|
||||
Use the ``db.in_transaction`` property to check whether a transaction opened with ``db.begin()`` or ``db.atomic()`` is currently open - on ``autocommit=False`` connections ``db.conn.in_transaction`` is always ``True``, so it cannot answer that question.
|
||||
|
||||
.. _python_api_table:
|
||||
|
||||
|
|
@ -504,9 +476,6 @@ You can also iterate through the table objects themselves using the ``.tables``
|
|||
>>> db.tables
|
||||
[<Table dogs>]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils tables <cli_tables>`
|
||||
|
||||
.. _python_api_views:
|
||||
|
||||
Listing views
|
||||
|
|
@ -532,9 +501,6 @@ View objects are similar to Table objects, except that any attempts to insert or
|
|||
* ``rows_where(where, where_args, order_by, select)``
|
||||
* ``drop()``
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils views <cli_views>`
|
||||
|
||||
.. _python_api_rows:
|
||||
|
||||
Listing rows
|
||||
|
|
@ -590,9 +556,6 @@ This method also accepts ``offset=`` and ``limit=`` arguments, for specifying an
|
|||
... print(row)
|
||||
{'id': 1, 'age': 4, 'name': 'Cleo'}
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rows <cli_rows>`
|
||||
|
||||
.. _python_api_rows_count_where:
|
||||
|
||||
Counting rows
|
||||
|
|
@ -677,9 +640,6 @@ The ``db.schema`` property returns the full SQL schema for the database as a str
|
|||
"name" TEXT
|
||||
);
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils schema <cli_schema>`
|
||||
|
||||
.. _python_api_creating_tables:
|
||||
|
||||
Creating tables
|
||||
|
|
@ -828,9 +788,6 @@ You can pass ``strict=True`` to create a table in ``STRICT`` mode:
|
|||
"name": str,
|
||||
}, strict=True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-table <cli_create_table>`
|
||||
|
||||
.. _python_api_compound_primary_keys:
|
||||
|
||||
Compound primary keys
|
||||
|
|
@ -1011,9 +968,6 @@ Here's an example that uses these features:
|
|||
# )
|
||||
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --not-null and --default <cli_defaults_not_null>`
|
||||
|
||||
.. _python_api_rename_table:
|
||||
|
||||
Renaming a table
|
||||
|
|
@ -1031,9 +985,6 @@ This executes the following SQL:
|
|||
|
||||
ALTER TABLE [my_table] RENAME TO [new_name_for_my_table]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rename-table <cli_renaming_tables>`
|
||||
|
||||
.. _python_api_duplicate:
|
||||
|
||||
Duplicating tables
|
||||
|
|
@ -1049,9 +1000,6 @@ The new ``authors_copy`` table will now contain a duplicate copy of the data fro
|
|||
|
||||
This method raises ``sqlite_utils.db.NoTable`` if the table does not exist.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils duplicate <cli_duplicate_table>`
|
||||
|
||||
.. _python_api_bulk_inserts:
|
||||
|
||||
Bulk inserts
|
||||
|
|
@ -1094,9 +1042,6 @@ You can delete all the existing rows in the table before inserting the new recor
|
|||
|
||||
Pass ``analyze=True`` to run ``ANALYZE`` against the table after inserting the new records.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert <cli_inserting_data>`
|
||||
|
||||
.. _python_api_insert_lists:
|
||||
|
||||
Inserting data from a list or tuple iterator
|
||||
|
|
@ -1174,9 +1119,6 @@ To replace any existing records that have a matching primary key, use the ``repl
|
|||
.. note::
|
||||
Prior to sqlite-utils 2.0 the ``.upsert()`` and ``.upsert_all()`` methods worked the same way as ``.insert(replace=True)`` does today. See :ref:`python_api_upsert` for the new behaviour of those methods introduced in 2.0.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --replace <cli_insert_replace>`
|
||||
|
||||
.. _python_api_update:
|
||||
|
||||
Updating a specific record
|
||||
|
|
@ -1262,9 +1204,6 @@ Every record passed to ``upsert()`` or ``upsert_all()`` must include a value for
|
|||
.. note::
|
||||
``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils upsert <cli_upsert>`
|
||||
|
||||
.. _python_api_old_upsert:
|
||||
|
||||
Alternative upserts using INSERT OR IGNORE
|
||||
|
|
@ -1616,9 +1555,6 @@ You can set a ``NOT NULL DEFAULT 'x'`` constraint on the new column using ``not_
|
|||
|
||||
db.table("dogs").add_column("friends_count", int, not_null_default=0)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-column <cli_add_column>`
|
||||
|
||||
.. _python_api_add_column_alter:
|
||||
|
||||
Adding columns automatically on insert/update
|
||||
|
|
@ -1642,9 +1578,6 @@ You can insert or update data that includes new columns and have the table autom
|
|||
new_table = db.table("new_table", alter=True)
|
||||
new_table.insert({"name": "Gareth", "age": 32, "shoe_size": 11})
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils insert --alter <cli_add_column_alter>`
|
||||
|
||||
.. _python_api_add_foreign_key:
|
||||
|
||||
Adding foreign key constraints
|
||||
|
|
@ -1703,9 +1636,6 @@ Use ``on_delete=`` and ``on_update=`` to specify ``ON DELETE`` and ``ON UPDATE``
|
|||
|
||||
This creates a foreign key with an ``ON DELETE CASCADE`` clause, so deleting an author will also delete their books (provided foreign key enforcement is enabled with ``PRAGMA foreign_keys = ON``). Valid actions are ``"SET NULL"``, ``"SET DEFAULT"``, ``"CASCADE"``, ``"RESTRICT"`` and the default ``"NO ACTION"``.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-foreign-key <cli_add_foreign_key>`
|
||||
|
||||
.. _python_api_add_foreign_keys:
|
||||
|
||||
Adding multiple foreign key constraints at once
|
||||
|
|
@ -1726,9 +1656,6 @@ This method runs the same checks as ``.add_foreign_keys()`` and will raise ``sql
|
|||
|
||||
Foreign keys that already exist are silently skipped, so repeated calls are idempotent - but only if they match exactly. Requesting a foreign key that exists with different ``ON DELETE``/``ON UPDATE`` actions raises ``AlterError``: use ``table.transform()`` to change the actions of an existing foreign key.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-foreign-keys <cli_add_foreign_keys>`
|
||||
|
||||
.. _python_api_index_foreign_keys:
|
||||
|
||||
Adding indexes for all foreign keys
|
||||
|
|
@ -1742,9 +1669,6 @@ If you want to ensure that every foreign key column in your database has a corre
|
|||
|
||||
Compound foreign keys get a single composite index across their columns.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils index-foreign-keys <cli_index_foreign_keys>`
|
||||
|
||||
.. _python_api_drop:
|
||||
|
||||
Dropping a table or view
|
||||
|
|
@ -1766,9 +1690,6 @@ Pass ``ignore=True`` if you want to ignore the error caused by the table or view
|
|||
|
||||
db.table("my_table").drop(ignore=True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils drop-table <cli_drop_table>` and :ref:`sqlite-utils drop-view <cli_drop_view>`
|
||||
|
||||
.. _python_api_transform:
|
||||
|
||||
Transforming a table
|
||||
|
|
@ -1797,9 +1718,6 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
|
|||
|
||||
This method raises a ``sqlite_utils.db.TransformError`` exception if the table cannot be transformed, usually because there are existing constraints or indexes that are incompatible with modifications to the columns.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils transform <cli_transform_table>`
|
||||
|
||||
.. _python_api_transform_alter_column_types:
|
||||
|
||||
Altering column types
|
||||
|
|
@ -1814,29 +1732,6 @@ To alter the type of a column, use the ``types=`` argument:
|
|||
|
||||
See :ref:`python_api_add_column` for a list of available types.
|
||||
|
||||
.. _python_api_transform_strict:
|
||||
|
||||
Changing strict mode
|
||||
--------------------
|
||||
|
||||
The optional ``strict=`` parameter can change whether a table uses `SQLite STRICT mode <https://www.sqlite.org/stricttables.html>`__. Pass ``strict=True`` to convert a regular table to a strict table:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
table.transform(strict=True)
|
||||
|
||||
Pass ``strict=False`` to convert a strict table back to a regular non-strict table:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
table.transform(strict=False)
|
||||
|
||||
The default is ``strict=None``, which preserves the table's existing strict mode.
|
||||
|
||||
Passing ``strict=True`` raises ``sqlite_utils.db.TransformError`` if the available SQLite version does not support strict tables.
|
||||
|
||||
Converting to a strict table validates all existing rows as they are copied into the replacement table. If a value is incompatible with its declared column type, SQLite raises ``sqlite3.IntegrityError`` and the transformation is rolled back, leaving the original table and its data unchanged.
|
||||
|
||||
.. _python_api_transform_rename_columns:
|
||||
|
||||
Renaming columns
|
||||
|
|
@ -1997,36 +1892,6 @@ If you want to do something more advanced, you can call the ``table.transform_sq
|
|||
|
||||
This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself.
|
||||
|
||||
.. _python_api_transform_foreign_keys_transactions:
|
||||
|
||||
Foreign keys and transactions
|
||||
-----------------------------
|
||||
|
||||
Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing.
|
||||
|
||||
``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from sqlite_utils.db import TransactionError
|
||||
|
||||
try:
|
||||
with db.atomic():
|
||||
db["authors"].transform(types={"id": str})
|
||||
except TransactionError as ex:
|
||||
print("Could not transform in transaction:", ex)
|
||||
|
||||
To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
db.execute("PRAGMA foreign_keys = off")
|
||||
with db.atomic():
|
||||
db["authors"].transform(types={"id": str})
|
||||
db.execute("PRAGMA foreign_keys = on")
|
||||
|
||||
Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits.
|
||||
|
||||
.. _python_api_extract:
|
||||
|
||||
Extracting columns into a separate table
|
||||
|
|
@ -2185,9 +2050,6 @@ This produces a lookup table like so:
|
|||
|
||||
Rows where every extracted column is ``null`` are not extracted: no record is created for them in the lookup table and their foreign key column is left as ``null``. When extracting multiple columns, rows where at least one of the extracted columns has a value will be extracted as usual.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils extract <cli_extract>`
|
||||
|
||||
.. _python_api_hash:
|
||||
|
||||
Setting an ID based on the hash of the row contents
|
||||
|
|
@ -2249,9 +2111,6 @@ You can pass ``ignore=True`` to silently ignore an existing view and do nothing,
|
|||
select * from dogs where is_good_dog = 1
|
||||
""", replace=True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-view <cli_create_view>`
|
||||
|
||||
Storing JSON
|
||||
============
|
||||
|
||||
|
|
@ -2370,9 +2229,6 @@ If you are using ``pysqlite3`` the underlying method may be missing. If you inst
|
|||
|
||||
pip install sqlite-dump
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils dump <cli_dump>`
|
||||
|
||||
.. _python_api_introspection:
|
||||
|
||||
Introspecting tables and views
|
||||
|
|
@ -2572,9 +2428,6 @@ The ``.indexes`` property returns all indexes created for a table, as a list of
|
|||
Index(seq=4, name='"Street_Tree_List_qCaretaker"', unique=0, origin='c', partial=0, columns=['qCaretaker']),
|
||||
Index(seq=5, name='"Street_Tree_List_PlantType"', unique=0, origin='c', partial=0, columns=['PlantType'])]
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils indexes <cli_indexes>`
|
||||
|
||||
.. _python_api_introspection_xindexes:
|
||||
|
||||
.xindexes
|
||||
|
|
@ -2618,9 +2471,6 @@ The ``.triggers`` property lists database triggers. It can be used on both datab
|
|||
>>> db.triggers
|
||||
... similar output to db.table("authors").triggers
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils triggers <cli_triggers>`
|
||||
|
||||
.. _python_api_introspection_triggers_dict:
|
||||
|
||||
.triggers_dict
|
||||
|
|
@ -2769,9 +2619,6 @@ To remove the FTS tables and triggers you created, use the ``disable_fts()`` tab
|
|||
|
||||
db.table("dogs").disable_fts()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-fts <cli_fts>`
|
||||
|
||||
.. _python_api_quote_fts:
|
||||
|
||||
Quoting characters for use in search
|
||||
|
|
@ -2836,9 +2683,6 @@ To return just the title and published columns for three matches for ``"dog"`` w
|
|||
):
|
||||
print(article)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils search <cli_search>`
|
||||
|
||||
.. _python_api_fts_search_sql:
|
||||
|
||||
Building SQL queries with table.search_sql()
|
||||
|
|
@ -2923,9 +2767,6 @@ This runs the following SQL::
|
|||
|
||||
INSERT INTO dogs_fts (dogs_fts) VALUES ("rebuild");
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils rebuild-fts <cli_fts>`
|
||||
|
||||
.. _python_api_fts_optimize:
|
||||
|
||||
Optimizing a full-text search table
|
||||
|
|
@ -2941,9 +2782,6 @@ This runs the following SQL::
|
|||
|
||||
INSERT INTO dogs_fts (dogs_fts) VALUES ("optimize");
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils optimize <cli_optimize>`
|
||||
|
||||
.. _python_api_cached_table_counts:
|
||||
|
||||
Cached table counts using triggers
|
||||
|
|
@ -3006,9 +2844,6 @@ If the ``_counts`` table ever becomes out-of-sync with the actual table counts y
|
|||
|
||||
db.reset_counts()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-counts <cli_enable_counts>`
|
||||
|
||||
.. _python_api_create_index:
|
||||
|
||||
Creating indexes
|
||||
|
|
@ -3060,9 +2895,6 @@ You can drop an index from a table using ``.drop_index(index_name)``:
|
|||
|
||||
Use ``ignore=True`` to ignore the error if the index does not exist.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-index <cli_create_index>` and :ref:`sqlite-utils drop-index <cli_drop_index>`
|
||||
|
||||
.. _python_api_analyze:
|
||||
|
||||
Optimizing index usage with ANALYZE
|
||||
|
|
@ -3090,9 +2922,6 @@ To run against all indexes attached to a specific table, you can either pass the
|
|||
|
||||
db.table("dogs").analyze()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils analyze <cli_analyze>`
|
||||
|
||||
.. _python_api_vacuum:
|
||||
|
||||
Vacuum
|
||||
|
|
@ -3104,9 +2933,6 @@ You can optimize your database by running VACUUM against it like so:
|
|||
|
||||
Database("my_database.db").vacuum()
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils vacuum <cli_vacuum>`
|
||||
|
||||
.. _python_api_wal:
|
||||
|
||||
WAL mode
|
||||
|
|
@ -3134,9 +2960,6 @@ You can check the current journal mode for a database using the ``journal_mode``
|
|||
|
||||
This will usually be ``wal`` or ``delete`` (meaning WAL is disabled), but can have other values - see the `PRAGMA journal_mode <https://www.sqlite.org/pragma.html#pragma_journal_mode>`__ documentation.
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils enable-wal and disable-wal <cli_wal>`
|
||||
|
||||
.. _python_api_suggest_column_types:
|
||||
|
||||
Suggesting column types
|
||||
|
|
@ -3277,9 +3100,6 @@ You can cause ``sqlite3`` to return more useful errors, including the traceback
|
|||
|
||||
sqlite3.enable_callback_tracebacks(True)
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils query --functions <cli_query_functions>`
|
||||
|
||||
.. _python_api_quote:
|
||||
|
||||
Quoting strings for use in SQL
|
||||
|
|
@ -3412,9 +3232,6 @@ Initialize SpatiaLite
|
|||
.. automethod:: sqlite_utils.db.Database.init_spatialite
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-database --init-spatialite <cli_create_database>`
|
||||
|
||||
.. _python_api_gis_find_spatialite:
|
||||
|
||||
Finding SpatiaLite
|
||||
|
|
@ -3430,9 +3247,6 @@ Adding geometry columns
|
|||
.. automethod:: sqlite_utils.db.Table.add_geometry_column
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils add-geometry-column <cli_spatialite>`
|
||||
|
||||
.. _python_api_gis_create_spatial_index:
|
||||
|
||||
Creating a spatial index
|
||||
|
|
@ -3440,6 +3254,3 @@ Creating a spatial index
|
|||
|
||||
.. automethod:: sqlite_utils.db.Table.create_spatial_index
|
||||
:noindex:
|
||||
|
||||
.. note::
|
||||
In the CLI: :ref:`sqlite-utils create-spatial-index <cli_spatialite_indexes>`
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Two related behavior changes to ``table.transform()``: compound foreign keys now
|
|||
- Multi-step operations such as ``table.transform()`` no longer commit an existing transaction you have open - they use savepoints inside it instead.
|
||||
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, instead of silently committing it.
|
||||
- Using ``Database`` as a context manager (``with Database(path) as db:``) closes the connection on exit *without* committing - a transaction you explicitly opened with ``db.begin()`` and did not commit is rolled back.
|
||||
- ``Database()`` rejects connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` or ``autocommit=False`` options, raising ``sqlite_utils.db.TransactionError``. On those connections every write the library made was silently discarded when the connection closed.
|
||||
- ``Database()`` accepts connections created with the Python 3.12+ ``sqlite3.connect(..., autocommit=True)`` and ``autocommit=False`` options, and adapts its transaction handling so all connection modes behave identically - see :ref:`python_api_transactions_modes`.
|
||||
|
||||
Packaging changes
|
||||
-----------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "sqlite-utils"
|
||||
version = "4.1.1"
|
||||
version = "4.0"
|
||||
description = "CLI tool and Python library for manipulating SQLite databases"
|
||||
readme = { file = "README.md", content-type = "text/markdown" }
|
||||
authors = [
|
||||
|
|
|
|||
|
|
@ -2718,11 +2718,6 @@ def schema(
|
|||
multiple=True,
|
||||
help="Drop foreign key constraint for this column",
|
||||
)
|
||||
@click.option(
|
||||
"--strict/--no-strict",
|
||||
default=None,
|
||||
help="Enable or disable STRICT mode (default: preserve current mode)",
|
||||
)
|
||||
@click.option("--sql", is_flag=True, help="Output SQL without executing it")
|
||||
@load_extension_option
|
||||
def transform(
|
||||
|
|
@ -2740,7 +2735,6 @@ def transform(
|
|||
default_none,
|
||||
add_foreign_keys,
|
||||
drop_foreign_keys,
|
||||
strict,
|
||||
sql,
|
||||
load_extension,
|
||||
):
|
||||
|
|
@ -2802,7 +2796,6 @@ def transform(
|
|||
defaults=default_dict,
|
||||
drop_foreign_keys=drop_foreign_keys_value,
|
||||
add_foreign_keys=add_foreign_keys_value,
|
||||
strict=strict,
|
||||
):
|
||||
click.echo(line)
|
||||
else:
|
||||
|
|
@ -2816,7 +2809,6 @@ def transform(
|
|||
defaults=default_dict,
|
||||
drop_foreign_keys=drop_foreign_keys_value,
|
||||
add_foreign_keys=add_foreign_keys_value,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -479,6 +479,48 @@ def _first_keyword(sql: str) -> str:
|
|||
return sql[i:j].upper()
|
||||
|
||||
|
||||
class _PrefetchedCursor:
|
||||
"""
|
||||
Stands in for a ``sqlite3.Cursor`` whose rows were fetched eagerly, so
|
||||
that the driver's implicit read transaction could be committed - used
|
||||
for row-returning statements on Python 3.12+ ``autocommit=False``
|
||||
connections, where a lazily-read cursor would hold the read transaction
|
||||
(and its shared lock) open indefinitely.
|
||||
"""
|
||||
|
||||
def __init__(self, cursor: sqlite3.Cursor, rows: list):
|
||||
self.description = cursor.description
|
||||
self.lastrowid = cursor.lastrowid
|
||||
self.rowcount = cursor.rowcount
|
||||
self.arraysize = cursor.arraysize
|
||||
self._rows = iter(rows)
|
||||
|
||||
def __iter__(self) -> "_PrefetchedCursor":
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
return next(self._rows)
|
||||
|
||||
def fetchone(self):
|
||||
return next(self._rows, None)
|
||||
|
||||
def fetchmany(self, size: Optional[int] = None) -> list:
|
||||
size = size or self.arraysize
|
||||
results = []
|
||||
for _ in range(size):
|
||||
row = next(self._rows, None)
|
||||
if row is None:
|
||||
break
|
||||
results.append(row)
|
||||
return results
|
||||
|
||||
def fetchall(self) -> list:
|
||||
return list(self._rows)
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Wrapper for a SQLite database connection that adds a variety of useful utility methods.
|
||||
|
|
@ -526,6 +568,10 @@ class Database:
|
|||
self.memory_name = None
|
||||
self.memory = False
|
||||
self.use_old_upsert = use_old_upsert
|
||||
# Tracks transaction ownership for autocommit=False connections,
|
||||
# where conn.in_transaction cannot distinguish the driver's implicit
|
||||
# transaction from one opened with begin() or atomic()
|
||||
self._explicit_transaction = False
|
||||
if not (
|
||||
(filename_or_conn is not None and (not memory and not memory_name))
|
||||
or (filename_or_conn is None and (memory or memory_name))
|
||||
|
|
@ -557,18 +603,6 @@ class Database:
|
|||
if recreate:
|
||||
raise ValueError("recreate cannot be used with connections, only paths")
|
||||
self.conn = cast(sqlite3.Connection, filename_or_conn)
|
||||
# Python 3.12+ autocommit=True/False connections make commit()
|
||||
# and rollback() behave differently, silently breaking the
|
||||
# transaction handling used by every write method
|
||||
autocommit = getattr(self.conn, "autocommit", None)
|
||||
if autocommit is not None and autocommit != getattr(
|
||||
sqlite3, "LEGACY_TRANSACTION_CONTROL", -1
|
||||
):
|
||||
raise TransactionError(
|
||||
"sqlite-utils requires a connection that uses the default "
|
||||
"transaction handling - connections created with "
|
||||
"autocommit=True or autocommit=False are not supported"
|
||||
)
|
||||
self._tracer: Optional[Tracer] = tracer
|
||||
if recursive_triggers:
|
||||
self.execute("PRAGMA recursive_triggers=on;")
|
||||
|
|
@ -594,6 +628,34 @@ class Database:
|
|||
"Close the SQLite connection, and the underlying database file"
|
||||
self.conn.close()
|
||||
|
||||
@property
|
||||
def _autocommit_false(self) -> bool:
|
||||
# True for Python 3.12+ sqlite3.connect(autocommit=False) connections,
|
||||
# where the driver holds an implicit transaction open at all times
|
||||
return getattr(self.conn, "autocommit", None) is False
|
||||
|
||||
def _commit_if_in_transaction(self) -> None:
|
||||
# On autocommit=False connections conn.commit() raises if no
|
||||
# transaction is active - which can happen after an error such as a
|
||||
# RAISE(ROLLBACK) trigger destroys the transaction
|
||||
if self.conn.in_transaction:
|
||||
self.conn.commit()
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
"""
|
||||
``True`` if a transaction opened with :meth:`begin` or :meth:`atomic`
|
||||
is currently open.
|
||||
|
||||
Unlike ``conn.in_transaction`` this is meaningful for every connection
|
||||
mode - on Python 3.12+ ``autocommit=False`` connections the driver
|
||||
keeps an implicit transaction open at all times, so
|
||||
``conn.in_transaction`` is always ``True`` there.
|
||||
"""
|
||||
if self._autocommit_false:
|
||||
return self._explicit_transaction
|
||||
return self.conn.in_transaction
|
||||
|
||||
@contextlib.contextmanager
|
||||
def atomic(self) -> Generator["Database", None, None]:
|
||||
"""
|
||||
|
|
@ -601,7 +663,7 @@ class Database:
|
|||
|
||||
Nested blocks use SQLite savepoints.
|
||||
"""
|
||||
if self.conn.in_transaction:
|
||||
if self.in_transaction:
|
||||
savepoint = "sqlite_utils_{}".format(secrets.token_hex(16))
|
||||
self.conn.execute("SAVEPOINT {};".format(savepoint))
|
||||
try:
|
||||
|
|
@ -618,7 +680,12 @@ class Database:
|
|||
else:
|
||||
self.conn.execute("RELEASE SAVEPOINT {};".format(savepoint))
|
||||
else:
|
||||
self.conn.execute("BEGIN")
|
||||
if self._autocommit_false:
|
||||
# The driver already holds an open implicit transaction -
|
||||
# claim it, so writes stop committing automatically
|
||||
self._explicit_transaction = True
|
||||
else:
|
||||
self.conn.execute("BEGIN")
|
||||
try:
|
||||
yield self
|
||||
except BaseException:
|
||||
|
|
@ -628,7 +695,11 @@ class Database:
|
|||
raise
|
||||
else:
|
||||
try:
|
||||
self.conn.execute("COMMIT")
|
||||
if self._autocommit_false:
|
||||
self._explicit_transaction = False
|
||||
self._commit_if_in_transaction()
|
||||
else:
|
||||
self.conn.execute("COMMIT")
|
||||
except BaseException:
|
||||
self.rollback()
|
||||
raise
|
||||
|
|
@ -643,12 +714,25 @@ class Database:
|
|||
Most code should use the :meth:`atomic` context manager instead, which
|
||||
commits and rolls back automatically. See :ref:`python_api_transactions`.
|
||||
"""
|
||||
if self._autocommit_false:
|
||||
if self._explicit_transaction:
|
||||
raise sqlite3.OperationalError(
|
||||
"cannot start a transaction within a transaction"
|
||||
)
|
||||
# The driver already holds an open implicit transaction - claim
|
||||
# it, so writes stop committing automatically
|
||||
self._explicit_transaction = True
|
||||
return
|
||||
self.execute("BEGIN")
|
||||
|
||||
def commit(self) -> None:
|
||||
"""
|
||||
Commit the current transaction. Does nothing if no transaction is open.
|
||||
"""
|
||||
if self._autocommit_false:
|
||||
self._explicit_transaction = False
|
||||
self._commit_if_in_transaction()
|
||||
return
|
||||
if self.conn.in_transaction:
|
||||
self.conn.execute("COMMIT")
|
||||
|
||||
|
|
@ -657,6 +741,11 @@ class Database:
|
|||
Roll back the current transaction, discarding its changes. Does nothing
|
||||
if no transaction is open.
|
||||
"""
|
||||
if self._autocommit_false:
|
||||
self._explicit_transaction = False
|
||||
if self.conn.in_transaction:
|
||||
self.conn.rollback()
|
||||
return
|
||||
if self.conn.in_transaction:
|
||||
self.conn.execute("ROLLBACK")
|
||||
|
||||
|
|
@ -682,11 +771,25 @@ class Database:
|
|||
``isolation_level`` would commit it as a side effect, silently
|
||||
breaking the caller's ability to roll back
|
||||
"""
|
||||
if self.conn.in_transaction:
|
||||
if self.in_transaction:
|
||||
raise TransactionError(
|
||||
"ensure_autocommit_on() cannot be used inside a transaction - "
|
||||
"changing isolation_level would commit the open transaction"
|
||||
)
|
||||
autocommit = getattr(self.conn, "autocommit", None)
|
||||
if autocommit is True:
|
||||
# Already in driver-level autocommit mode
|
||||
yield
|
||||
return
|
||||
if autocommit is False:
|
||||
# Setting autocommit = True commits the driver's implicit
|
||||
# transaction - safe, because no user transaction is open
|
||||
setattr(self.conn, "autocommit", True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
setattr(self.conn, "autocommit", False)
|
||||
return
|
||||
old_isolation_level = self.conn.isolation_level
|
||||
try:
|
||||
self.conn.isolation_level = None
|
||||
|
|
@ -849,11 +952,17 @@ class Database:
|
|||
# execute these without the savepoint guard used below. Some
|
||||
# adapters open an implicit transaction before comment-prefixed
|
||||
# PRAGMAs, so temporarily use driver autocommit when it is safe.
|
||||
if self.conn.in_transaction:
|
||||
if self.in_transaction:
|
||||
cursor = self.conn.execute(sql, *args)
|
||||
else:
|
||||
fetch_eagerly = self._autocommit_false
|
||||
with self.ensure_autocommit_on():
|
||||
cursor = self.conn.execute(sql, *args)
|
||||
if fetch_eagerly and cursor.description is not None:
|
||||
# Rows read lazily after autocommit is restored
|
||||
# would hold a read transaction open
|
||||
keys = dedupe_keys(d[0] for d in cursor.description)
|
||||
return (dict(zip(keys, row)) for row in cursor.fetchall())
|
||||
if cursor.description is None:
|
||||
raise ValueError(message)
|
||||
keys = dedupe_keys(d[0] for d in cursor.description)
|
||||
|
|
@ -867,6 +976,16 @@ class Database:
|
|||
if cursor.description is None:
|
||||
raise ValueError(message)
|
||||
keys = dedupe_keys(d[0] for d in cursor.description)
|
||||
if self._autocommit_false and not self._explicit_transaction:
|
||||
# Fetch eagerly and commit the driver's implicit transaction.
|
||||
# This commits any write (INSERT ... RETURNING) immediately,
|
||||
# and releases the read snapshot which would otherwise stay
|
||||
# open indefinitely, blocking writes from other connections
|
||||
fetched = cursor.fetchall()
|
||||
self.conn.execute('RELEASE "sqlite_utils_query"')
|
||||
released = True
|
||||
self._commit_if_in_transaction()
|
||||
return (dict(zip(keys, row)) for row in fetched)
|
||||
try:
|
||||
self.conn.execute('RELEASE "sqlite_utils_query"')
|
||||
released = True
|
||||
|
|
@ -879,6 +998,10 @@ class Database:
|
|||
fetched = cursor.fetchall()
|
||||
self.conn.execute('RELEASE "sqlite_utils_query"')
|
||||
released = True
|
||||
if self._autocommit_false and not self._explicit_transaction:
|
||||
# Releasing the savepoint cannot commit here, because the
|
||||
# driver's implicit transaction encloses it
|
||||
self._commit_if_in_transaction()
|
||||
return (dict(zip(keys, row)) for row in fetched)
|
||||
return (dict(zip(keys, row)) for row in cursor)
|
||||
finally:
|
||||
|
|
@ -889,6 +1012,9 @@ class Database:
|
|||
# and there is nothing left to undo
|
||||
self.conn.execute('ROLLBACK TO "sqlite_utils_query"')
|
||||
self.conn.execute('RELEASE "sqlite_utils_query"')
|
||||
if self._autocommit_false and not self._explicit_transaction:
|
||||
# Also close the driver's implicit transaction
|
||||
self.conn.rollback()
|
||||
|
||||
def execute(
|
||||
self, sql: str, parameters: Optional[Union[Sequence, Dict[str, Any]]] = None
|
||||
|
|
@ -907,6 +1033,8 @@ class Database:
|
|||
"""
|
||||
if self._tracer:
|
||||
self._tracer(sql, parameters)
|
||||
if self._autocommit_false:
|
||||
return self._execute_autocommit_false(sql, parameters)
|
||||
was_in_transaction = self.conn.in_transaction
|
||||
try:
|
||||
if parameters is not None:
|
||||
|
|
@ -932,6 +1060,68 @@ class Database:
|
|||
self.conn.execute("COMMIT")
|
||||
return cursor
|
||||
|
||||
def _execute_autocommit_false(
|
||||
self, sql: str, parameters: Optional[Union[Sequence, Dict[str, Any]]] = None
|
||||
) -> sqlite3.Cursor:
|
||||
# execute() for Python 3.12+ autocommit=False connections, where the
|
||||
# driver holds an implicit transaction open at all times. BEGIN,
|
||||
# COMMIT and ROLLBACK are rerouted through begin()/commit()/rollback()
|
||||
# so they behave identically to the other connection modes - the
|
||||
# driver would otherwise reject BEGIN outright
|
||||
keyword = _first_keyword(sql)
|
||||
if keyword == "BEGIN":
|
||||
self.begin()
|
||||
return self.conn.cursor()
|
||||
if keyword in ("COMMIT", "END"):
|
||||
self.commit()
|
||||
return self.conn.cursor()
|
||||
if keyword == "ROLLBACK" and "TO" not in sql.upper().split():
|
||||
# A full ROLLBACK - but not ROLLBACK TO SAVEPOINT, which runs
|
||||
# inside the transaction
|
||||
self.rollback()
|
||||
return self.conn.cursor()
|
||||
if keyword in ("PRAGMA", "VACUUM") and not self._explicit_transaction:
|
||||
# PRAGMA statements such as foreign_keys are silently ignored
|
||||
# inside a transaction - and VACUUM refuses to run in one - but
|
||||
# the driver's implicit transaction never closes. Run them in
|
||||
# driver autocommit mode instead
|
||||
with self.ensure_autocommit_on():
|
||||
if parameters is not None:
|
||||
cursor = self.conn.execute(sql, parameters)
|
||||
else:
|
||||
cursor = self.conn.execute(sql)
|
||||
if cursor.description is not None:
|
||||
# Fetch eagerly - rows read lazily after autocommit is
|
||||
# restored would hold a read transaction open
|
||||
return cast(
|
||||
sqlite3.Cursor, _PrefetchedCursor(cursor, cursor.fetchall())
|
||||
)
|
||||
return cursor
|
||||
try:
|
||||
if parameters is not None:
|
||||
cursor = self.conn.execute(sql, parameters)
|
||||
else:
|
||||
cursor = self.conn.execute(sql)
|
||||
except Exception:
|
||||
if not self._explicit_transaction and self.conn.in_transaction:
|
||||
# Discard whatever the failed statement left in the driver's
|
||||
# implicit transaction, matching the other connection modes
|
||||
self.conn.rollback()
|
||||
raise
|
||||
if not self._explicit_transaction:
|
||||
if cursor.description is not None:
|
||||
# A row-returning statement. Fetch eagerly and commit, so the
|
||||
# driver's implicit read transaction - and the shared lock
|
||||
# that blocks writes from other connections - is released
|
||||
rows = cursor.fetchall()
|
||||
self._commit_if_in_transaction()
|
||||
return cast(sqlite3.Cursor, _PrefetchedCursor(cursor, rows))
|
||||
if keyword not in _TRANSACTION_CONTROL_KEYWORDS:
|
||||
# No user transaction is open - commit the driver's implicit
|
||||
# transaction, so writes behave identically across modes
|
||||
self._commit_if_in_transaction()
|
||||
return cursor
|
||||
|
||||
def executescript(self, sql: str) -> sqlite3.Cursor:
|
||||
"""
|
||||
Execute multiple SQL statements separated by ; and return the ``sqlite3.Cursor``.
|
||||
|
|
@ -943,13 +1133,18 @@ class Database:
|
|||
return self._executescript(sql)
|
||||
|
||||
def _executescript(self, sql: str) -> sqlite3.Cursor:
|
||||
if self.conn.in_transaction:
|
||||
if self.in_transaction:
|
||||
cursor = self.conn.cursor()
|
||||
# avoid sqlite3.executescript()'s implicit commit:
|
||||
for statement in _iter_complete_sql_statements(sql):
|
||||
cursor.execute(statement)
|
||||
return cursor
|
||||
return self.conn.executescript(sql)
|
||||
cursor = self.conn.executescript(sql)
|
||||
if self._autocommit_false:
|
||||
# executescript() does not commit in this mode - do it here, so
|
||||
# scripts behave identically across connection modes
|
||||
self._commit_if_in_transaction()
|
||||
return cursor
|
||||
|
||||
def table(self, table_name: str, **kwargs: Any) -> "Table":
|
||||
"""
|
||||
|
|
@ -1189,7 +1384,7 @@ class Database:
|
|||
# Changing journal mode assigns conn.isolation_level, which commits
|
||||
# any open transaction as a side effect - breaking the rollback
|
||||
# guarantee of atomic() and of user-managed transactions
|
||||
if self.conn.in_transaction:
|
||||
if self.in_transaction:
|
||||
raise TransactionError(
|
||||
"{} cannot be used while a transaction is open".format(operation)
|
||||
)
|
||||
|
|
@ -1881,7 +2076,7 @@ class Database:
|
|||
for table, fks in by_table.items():
|
||||
self.table(table).transform(add_foreign_keys=fks)
|
||||
|
||||
if not self.conn.in_transaction:
|
||||
if not self.in_transaction:
|
||||
self.vacuum()
|
||||
|
||||
def index_foreign_keys(self) -> None:
|
||||
|
|
@ -1897,7 +2092,13 @@ class Database:
|
|||
|
||||
def vacuum(self) -> None:
|
||||
"Run a SQLite ``VACUUM`` against the database."
|
||||
self.execute("VACUUM;")
|
||||
if self.in_transaction:
|
||||
# Fails with OperationalError, as VACUUM always does inside a
|
||||
# transaction - executed anyway for identical errors across modes
|
||||
self.execute("VACUUM;")
|
||||
else:
|
||||
with self.ensure_autocommit_on():
|
||||
self.execute("VACUUM;")
|
||||
|
||||
def analyze(self, name: Optional[str] = None) -> None:
|
||||
"""
|
||||
|
|
@ -2514,7 +2715,6 @@ class Table(Queryable):
|
|||
foreign_keys: Optional[ForeignKeysType] = None,
|
||||
column_order: Optional[List[str]] = None,
|
||||
keep_table: Optional[str] = None,
|
||||
strict: Optional[bool] = None,
|
||||
) -> "Table":
|
||||
"""
|
||||
Apply an advanced alter table, including operations that are not supported by
|
||||
|
|
@ -2522,11 +2722,6 @@ class Table(Queryable):
|
|||
|
||||
See :ref:`python_api_transform` for full details.
|
||||
|
||||
Raises :py:class:`sqlite_utils.db.TransactionError` if called while a
|
||||
transaction is open with ``PRAGMA foreign_keys`` enabled and the table
|
||||
is referenced by foreign keys with destructive ``ON DELETE`` actions -
|
||||
see :ref:`python_api_transform_foreign_keys_transactions`.
|
||||
|
||||
:param types: Columns that should have their type changed, for example ``{"weight": float}``
|
||||
:param rename: Columns to rename, for example ``{"headline": "title"}``
|
||||
:param drop: Columns to drop
|
||||
|
|
@ -2542,8 +2737,6 @@ class Table(Queryable):
|
|||
to use when creating the table
|
||||
:param keep_table: If specified, the existing table will be renamed to this and will not be
|
||||
dropped
|
||||
:param strict: Set to ``True`` to make the table strict or ``False`` to make it
|
||||
non-strict. Defaults to ``None``, which preserves the existing strict mode.
|
||||
"""
|
||||
if not self.exists():
|
||||
raise ValueError("Cannot transform a table that doesn't exist yet")
|
||||
|
|
@ -2559,48 +2752,17 @@ class Table(Queryable):
|
|||
foreign_keys=foreign_keys,
|
||||
column_order=column_order,
|
||||
keep_table=keep_table,
|
||||
strict=strict,
|
||||
)
|
||||
pragma_foreign_keys_was_on = bool(
|
||||
self.db.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
)
|
||||
already_in_transaction = self.db.conn.in_transaction
|
||||
already_in_transaction = self.db.in_transaction
|
||||
should_disable_foreign_keys = (
|
||||
pragma_foreign_keys_was_on and not already_in_transaction
|
||||
)
|
||||
should_defer_foreign_keys = (
|
||||
pragma_foreign_keys_was_on and already_in_transaction
|
||||
)
|
||||
if should_defer_foreign_keys:
|
||||
# PRAGMA foreign_keys is a no-op inside a transaction, and
|
||||
# defer_foreign_keys only defers violation checks, not ON DELETE
|
||||
# actions - so dropping the old table would still fire destructive
|
||||
# actions on any tables that reference it. Refuse rather than
|
||||
# silently modify or delete those rows.
|
||||
destructive_fks = [
|
||||
(table.name, fk)
|
||||
for table in self.db.tables
|
||||
for fk in table.foreign_keys
|
||||
if fk.other_table == self.name
|
||||
and fk.on_delete in ("CASCADE", "SET NULL", "SET DEFAULT")
|
||||
]
|
||||
if destructive_fks:
|
||||
raise TransactionError(
|
||||
"Cannot transform table {table} while a transaction is open: "
|
||||
"PRAGMA foreign_keys cannot be changed inside a transaction, "
|
||||
"and the table is referenced by foreign keys with ON DELETE "
|
||||
"actions that would fire when the old table is dropped: "
|
||||
"{fks}. Call transform() outside of the transaction, or "
|
||||
'execute "PRAGMA foreign_keys = off" before opening it.'.format(
|
||||
table=self.name,
|
||||
fks=", ".join(
|
||||
"{}.{} (ON DELETE {})".format(
|
||||
table_name, ", ".join(fk.columns), fk.on_delete
|
||||
)
|
||||
for table_name, fk in destructive_fks
|
||||
),
|
||||
)
|
||||
)
|
||||
defer_foreign_keys_was_on = False
|
||||
try:
|
||||
if should_disable_foreign_keys:
|
||||
|
|
@ -2626,8 +2788,6 @@ class Table(Queryable):
|
|||
self.db.execute("PRAGMA defer_foreign_keys=OFF;")
|
||||
if should_disable_foreign_keys:
|
||||
self.db.execute("PRAGMA foreign_keys=1;")
|
||||
if strict is not None:
|
||||
self._defaults["strict"] = strict
|
||||
return self
|
||||
|
||||
def transform_sql(
|
||||
|
|
@ -2645,7 +2805,6 @@ class Table(Queryable):
|
|||
column_order: Optional[List[str]] = None,
|
||||
tmp_suffix: Optional[str] = None,
|
||||
keep_table: Optional[str] = None,
|
||||
strict: Optional[bool] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Return a list of SQL statements that should be executed in order to apply this transformation.
|
||||
|
|
@ -2666,11 +2825,7 @@ class Table(Queryable):
|
|||
:param tmp_suffix: Suffix to use for the temporary table name
|
||||
:param keep_table: If specified, the existing table will be renamed to this and will not be
|
||||
dropped
|
||||
:param strict: Set to ``True`` to make the table strict or ``False`` to make it
|
||||
non-strict. Defaults to ``None``, which preserves the existing strict mode.
|
||||
"""
|
||||
if strict is True and not self.db.supports_strict:
|
||||
raise TransformError("SQLite does not support STRICT tables")
|
||||
types = types or {}
|
||||
rename = rename or {}
|
||||
drop = drop or set()
|
||||
|
|
@ -2852,7 +3007,7 @@ class Table(Queryable):
|
|||
defaults=create_table_defaults,
|
||||
foreign_keys=create_table_foreign_keys,
|
||||
column_order=column_order,
|
||||
strict=self.strict if strict is None else strict,
|
||||
strict=self.strict,
|
||||
).strip()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ def pytest_addoption(parser):
|
|||
"sqlite3.connect(autocommit=True) mode"
|
||||
),
|
||||
)
|
||||
parser.addoption(
|
||||
"--sqlite-autocommit-false",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Run every test against connections created with the Python 3.12+ "
|
||||
"sqlite3.connect(autocommit=False) mode"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
|
|
@ -25,15 +34,22 @@ def pytest_configure(config):
|
|||
|
||||
sys._called_from_test = True # type: ignore[attr-defined]
|
||||
|
||||
if config.getoption("--sqlite-autocommit"):
|
||||
autocommit_true = config.getoption("--sqlite-autocommit")
|
||||
autocommit_false = config.getoption("--sqlite-autocommit-false")
|
||||
if autocommit_true and autocommit_false:
|
||||
raise pytest.UsageError(
|
||||
"--sqlite-autocommit and --sqlite-autocommit-false are mutually exclusive"
|
||||
)
|
||||
if autocommit_true or autocommit_false:
|
||||
if sys.version_info < (3, 12):
|
||||
raise pytest.UsageError(
|
||||
"--sqlite-autocommit requires Python 3.12 or higher"
|
||||
"--sqlite-autocommit and --sqlite-autocommit-false require "
|
||||
"Python 3.12 or higher"
|
||||
)
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def autocommit_connect(*args, **kwargs):
|
||||
kwargs.setdefault("autocommit", True)
|
||||
kwargs.setdefault("autocommit", autocommit_true)
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
sqlite3.connect = autocommit_connect
|
||||
|
|
@ -81,5 +97,6 @@ def db_path(tmpdir):
|
|||
path = str(tmpdir / "test.db")
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript(CREATE_TABLES)
|
||||
db.commit()
|
||||
db.close()
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -135,8 +135,13 @@ def test_analyze_column(db_to_analyze, column, extra_kwargs, expected):
|
|||
def db_to_analyze_path(db_to_analyze, tmpdir):
|
||||
path = str(tmpdir / "test.db")
|
||||
db = sqlite3.connect(path)
|
||||
if getattr(db, "autocommit", None) is False:
|
||||
# iterdump scripts contain BEGIN TRANSACTION, which the driver's
|
||||
# always-open implicit transaction would reject
|
||||
db.autocommit = True
|
||||
sql = "\n".join(db_to_analyze.iterdump())
|
||||
db.executescript(sql)
|
||||
db.commit()
|
||||
db.close()
|
||||
return path
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def test_transform_does_not_commit_open_atomic_block(fresh_db):
|
|||
|
||||
|
||||
def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
{"id": 1, "title": "Book", "author_id": 1},
|
||||
|
|
@ -141,7 +141,7 @@ def test_transform_parent_table_with_foreign_keys_in_atomic(fresh_db):
|
|||
|
||||
|
||||
def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
{"id": 1, "title": "Book", "author_id": 1},
|
||||
|
|
@ -163,7 +163,7 @@ def test_transform_parent_table_with_foreign_keys_rolls_back(fresh_db):
|
|||
|
||||
|
||||
def test_transform_detects_foreign_key_check_violations(fresh_db):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert({"id": 1, "author_id": 2}, pk="id")
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db):
|
|||
with fresh_db.atomic():
|
||||
fresh_db["t"].insert({"id": 2}, pk="id")
|
||||
# Nothing is committed until the user's own transaction commits
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
# And with a commit instead, the atomic block's writes persist
|
||||
|
|
@ -197,9 +197,9 @@ def test_begin_commit_rollback(tmpdir):
|
|||
db["t"].insert({"id": 1}, pk="id")
|
||||
db.begin()
|
||||
db["t"].insert({"id": 2}, pk="id")
|
||||
assert db.conn.in_transaction
|
||||
assert db.in_transaction
|
||||
db.rollback()
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
assert [r["id"] for r in db["t"].rows] == [1]
|
||||
db.begin()
|
||||
db["t"].insert({"id": 3}, pk="id")
|
||||
|
|
@ -220,7 +220,7 @@ def test_begin_inside_transaction_errors(fresh_db):
|
|||
def test_commit_and_rollback_without_transaction_are_noops(fresh_db):
|
||||
fresh_db.commit()
|
||||
fresh_db.rollback()
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
||||
|
||||
def test_execute_write_commits_immediately(tmpdir):
|
||||
|
|
@ -229,7 +229,7 @@ def test_execute_write_commits_immediately(tmpdir):
|
|||
db["t"].insert({"id": 1}, pk="id")
|
||||
db.execute("insert into t (id) values (2)")
|
||||
# No implicit transaction is left open
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
# A completely separate connection sees the row straight away
|
||||
other = sqlite3.connect(path)
|
||||
assert other.execute("select count(*) from t").fetchone()[0] == 2
|
||||
|
|
@ -242,7 +242,7 @@ def test_execute_write_respects_explicit_transaction(fresh_db):
|
|||
fresh_db.begin()
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
# Still inside the explicit transaction - not committed
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
|
||||
|
|
@ -252,7 +252,7 @@ def test_execute_comment_prefixed_begin_leaves_transaction_open(fresh_db):
|
|||
# out from under the caller
|
||||
fresh_db["t"].insert({"id": 1}, pk="id")
|
||||
fresh_db.execute("-- start a transaction\nbegin")
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
|
|
@ -275,7 +275,7 @@ def test_execute_prefixed_begin_leaves_transaction_open(fresh_db, begin_sql):
|
|||
pytest.skip("This SQLite version rejects a leading byte order mark")
|
||||
fresh_db["t"].insert({"id": 1}, pk="id")
|
||||
fresh_db.execute(begin_sql)
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.execute("insert into t (id) values (2)")
|
||||
fresh_db.rollback()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1]
|
||||
|
|
@ -289,7 +289,7 @@ def test_execute_failed_write_rolls_back_implicit_transaction(tmpdir):
|
|||
db["t"].insert({"id": 1}, pk="id")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute("insert into t (id) values (1)")
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
# Subsequent writes commit as normal and survive closing the connection
|
||||
db["other"].insert({"id": 2})
|
||||
db.close()
|
||||
|
|
@ -306,7 +306,7 @@ def test_execute_failed_write_preserves_explicit_transaction(fresh_db):
|
|||
fresh_db.execute("insert into t (id) values (2)")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
fresh_db.execute("insert into t (id) values (1)")
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.commit()
|
||||
assert [r["id"] for r in fresh_db["t"].rows] == [1, 2]
|
||||
|
||||
|
|
@ -332,7 +332,7 @@ def test_query_returning_commits_after_iteration(tmpdir):
|
|||
db["t"].insert({"id": 1}, pk="id")
|
||||
rows = list(db.query("insert into t (id) values (2) returning id"))
|
||||
assert rows == [{"id": 2}]
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
other = sqlite3.connect(path)
|
||||
assert other.execute("select count(*) from t").fetchone()[0] == 2
|
||||
other.close()
|
||||
|
|
@ -357,7 +357,7 @@ def test_atomic_preserves_error_from_transaction_destroying_trigger(fresh_db):
|
|||
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
|
||||
with fresh_db.atomic():
|
||||
fresh_db.execute("insert into t (v) values ('bad')")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
||||
|
||||
def test_nested_atomic_preserves_error_from_transaction_destroying_trigger(
|
||||
|
|
@ -371,7 +371,7 @@ def test_nested_atomic_preserves_error_from_transaction_destroying_trigger(
|
|||
with fresh_db.atomic():
|
||||
with fresh_db.atomic():
|
||||
fresh_db.execute("insert into t (v) values ('bad')")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
||||
|
||||
def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
|
||||
|
|
@ -379,4 +379,4 @@ def test_atomic_preserves_error_from_insert_or_rollback(fresh_db):
|
|||
with pytest.raises(sqlite3.IntegrityError):
|
||||
with fresh_db.atomic():
|
||||
fresh_db.execute("insert or rollback into t (id) values (1)")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from sqlite_utils.db import Index, ForeignKey
|
|||
from click.testing import CliRunner
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sqlite3
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
|
@ -1940,64 +1939,6 @@ def test_transform_sql(db_path):
|
|||
assert db["dogs"].schema == original_schema
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_strict,args,expected_strict",
|
||||
(
|
||||
(False, [], False),
|
||||
(True, [], True),
|
||||
(False, ["--strict"], True),
|
||||
(True, ["--no-strict"], False),
|
||||
),
|
||||
)
|
||||
def test_transform_strict_option(db_path, initial_strict, args, expected_strict):
|
||||
db = Database(db_path)
|
||||
if not db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
db["dogs"].create({"id": int}, strict=initial_strict)
|
||||
|
||||
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs"] + args)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert db["dogs"].strict is expected_strict
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_strict,flag,sql_is_strict",
|
||||
(
|
||||
(False, "--strict", True),
|
||||
(True, "--no-strict", False),
|
||||
),
|
||||
)
|
||||
def test_transform_strict_option_sql(db_path, initial_strict, flag, sql_is_strict):
|
||||
db = Database(db_path)
|
||||
if not db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
db["dogs"].create({"id": int}, strict=initial_strict)
|
||||
|
||||
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", flag, "--sql"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (") STRICT;" in result.output) is sql_is_strict
|
||||
assert db["dogs"].strict is initial_strict
|
||||
|
||||
|
||||
def test_transform_strict_option_with_invalid_data(db_path):
|
||||
db = Database(db_path)
|
||||
if not db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
dogs = db["dogs"]
|
||||
dogs.create({"id": int})
|
||||
dogs.insert({"id": "not-an-integer"})
|
||||
|
||||
result = CliRunner().invoke(cli.cli, ["transform", db_path, "dogs", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, sqlite3.IntegrityError)
|
||||
assert dogs.strict is False
|
||||
assert list(dogs.rows) == [{"id": "not-an-integer"}]
|
||||
assert not any(name.startswith("dogs_new_") for name in db.table_names())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_args,expected_schema",
|
||||
(
|
||||
|
|
@ -2259,7 +2200,7 @@ def test_search_quote(tmpdir):
|
|||
def test_indexes(tmpdir):
|
||||
db_path = str(tmpdir / "test.db")
|
||||
db = Database(db_path)
|
||||
db.conn.executescript("""
|
||||
db.executescript("""
|
||||
create table Gosh (c1 text, c2 text, c3 text);
|
||||
create index Gosh_idx on Gosh(c2, c3 desc);
|
||||
""")
|
||||
|
|
@ -2353,7 +2294,7 @@ def test_triggers(tmpdir, extra_args, expected):
|
|||
pk="id",
|
||||
)
|
||||
db["counter"].insert({"count": 1})
|
||||
db.conn.execute(textwrap.dedent("""
|
||||
db.execute(textwrap.dedent("""
|
||||
CREATE TRIGGER blah AFTER INSERT ON articles
|
||||
BEGIN
|
||||
UPDATE counter SET count = count + 1;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from sqlite_utils import Database
|
||||
from sqlite_utils.db import TransactionError
|
||||
from sqlite_utils.utils import sqlite3
|
||||
import pytest
|
||||
import sys
|
||||
|
|
@ -66,13 +65,61 @@ def test_database_close(tmpdir, memory):
|
|||
reason="sqlite3.connect(autocommit=) requires Python 3.12",
|
||||
)
|
||||
@pytest.mark.parametrize("autocommit", [True, False])
|
||||
def test_autocommit_connections_are_rejected(tmpdir, autocommit):
|
||||
# These connection modes break commit()/rollback() in ways that
|
||||
# silently lose data, so the constructor refuses them
|
||||
def test_autocommit_connection_writes_persist(tmpdir, autocommit):
|
||||
path = str(tmpdir / "test.db")
|
||||
conn = sqlite3.connect(path, autocommit=autocommit)
|
||||
db = Database(conn)
|
||||
db["t"].insert({"id": 1}, pk="id")
|
||||
db.execute("insert into t (id) values (2)")
|
||||
db.close()
|
||||
# The writes survived closing the connection
|
||||
db2 = Database(path)
|
||||
assert [r["id"] for r in db2["t"].rows] == [1, 2]
|
||||
db2.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 12),
|
||||
reason="sqlite3.connect(autocommit=) requires Python 3.12",
|
||||
)
|
||||
@pytest.mark.parametrize("autocommit", [True, False])
|
||||
def test_autocommit_connection_transactions(tmpdir, autocommit):
|
||||
conn = sqlite3.connect(str(tmpdir / "test.db"), autocommit=autocommit)
|
||||
with pytest.raises(TransactionError):
|
||||
Database(conn)
|
||||
conn.close()
|
||||
db = Database(conn)
|
||||
db["t"].insert({"id": 1}, pk="id")
|
||||
# atomic() rolls back on error
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
with db.atomic():
|
||||
db["t"].insert({"id": 2}, pk="id")
|
||||
1 / 0
|
||||
assert [r["id"] for r in db["t"].rows] == [1]
|
||||
# atomic() commits on success, including a nested block
|
||||
with db.atomic():
|
||||
db["t"].insert({"id": 3}, pk="id")
|
||||
with db.atomic():
|
||||
db["t"].insert({"id": 4}, pk="id")
|
||||
assert [r["id"] for r in db["t"].rows] == [1, 3, 4]
|
||||
# begin()/rollback() work too
|
||||
db.begin()
|
||||
db["t"].insert({"id": 5}, pk="id")
|
||||
db.rollback()
|
||||
assert [r["id"] for r in db["t"].rows] == [1, 3, 4]
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 12),
|
||||
reason="sqlite3.connect(autocommit=) requires Python 3.12",
|
||||
)
|
||||
@pytest.mark.parametrize("autocommit", [True, False])
|
||||
def test_autocommit_connection_wal(tmpdir, autocommit):
|
||||
conn = sqlite3.connect(str(tmpdir / "test.db"), autocommit=autocommit)
|
||||
db = Database(conn)
|
||||
db.enable_wal()
|
||||
assert db.journal_mode == "wal"
|
||||
db.disable_wal()
|
||||
assert db.journal_mode == "delete"
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
|
@ -111,3 +158,10 @@ def test_memory_attribute_for_file_path(tmpdir):
|
|||
db = Database(str(tmpdir / "file.db"))
|
||||
assert db.memory is False
|
||||
assert db.memory_name is None
|
||||
|
||||
|
||||
def test_memory_attribute_for_existing_connection():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
db = Database(conn)
|
||||
assert db.memory is False
|
||||
assert db.memory_name is None
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def test_delete_where_commits(tmpdir):
|
|||
db["table"].delete_where("id > ?", [2])
|
||||
# The connection must not be left inside an open transaction,
|
||||
# otherwise subsequent atomic() blocks never commit either
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
db["table"].insert({"id": 100})
|
||||
db.close()
|
||||
db2 = sqlite_utils.Database(path)
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ def test_optimize_and_rebuild_fts_commit(tmpdir, method):
|
|||
getattr(table, method)()
|
||||
# The connection must not be left inside an open transaction,
|
||||
# otherwise this and all subsequent writes are lost on close
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
table.insert(search_records[1])
|
||||
db.close()
|
||||
db2 = Database(path)
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ def test_query_rejects_statements_that_return_no_rows(fresh_db):
|
|||
fresh_db.query("update dogs set name = 'Cleopaws'")
|
||||
assert "execute()" in str(ex.value)
|
||||
# The rejected update was rolled back, and no transaction is left open
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
|
||||
|
||||
|
||||
def test_query_rejected_ddl_is_rolled_back(fresh_db):
|
||||
with pytest.raises(ValueError):
|
||||
fresh_db.query("create table dogs (id integer primary key)")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
assert fresh_db.table_names() == []
|
||||
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ def test_query_rejected_write_inside_transaction_is_rolled_back(fresh_db):
|
|||
with pytest.raises(ValueError):
|
||||
fresh_db.query("update dogs set name = 'Cleopaws'")
|
||||
# The transaction is still open and the earlier insert is intact
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.commit()
|
||||
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo", "Pancakes"]
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ def test_query_rejects_transaction_control_and_vacuum(fresh_db, sql):
|
|||
with pytest.raises(ValueError) as ex:
|
||||
fresh_db.query(sql)
|
||||
assert "execute()" in str(ex.value)
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
||||
|
||||
def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
|
||||
|
|
@ -82,7 +82,7 @@ def test_query_comment_prefixed_commit_does_not_commit_transaction(fresh_db):
|
|||
with pytest.raises(ValueError):
|
||||
fresh_db.query("/* comment */ COMMIT")
|
||||
# The explicit transaction is still open and can still be rolled back
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ def test_query_prefixed_commit_does_not_commit_transaction(fresh_db, sql):
|
|||
with pytest.raises(ValueError):
|
||||
fresh_db.query(sql)
|
||||
# The explicit transaction is still open and can still be rolled back
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ def test_query_prefixed_commit_does_not_commit_transaction(fresh_db, sql):
|
|||
def test_query_error_leaves_no_transaction_open(fresh_db):
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
fresh_db.query("select * from missing_table")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
|
||||
|
||||
def test_query_pragma(tmpdir):
|
||||
|
|
@ -152,7 +152,7 @@ def test_query_comment_prefixed_pragma_inside_transaction(fresh_db):
|
|||
assert list(fresh_db.query("-- check version\npragma user_version")) == [
|
||||
{"user_version": 0}
|
||||
]
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ def test_query_insert_returning_commits_without_iteration(tmpdir):
|
|||
db["dogs"].insert({"name": "Cleo"})
|
||||
# Never iterate over the results
|
||||
db.query("insert into dogs (name) values ('Pancakes') returning name")
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
# A completely separate connection sees the new row straight away
|
||||
other = sqlite3.connect(path)
|
||||
assert other.execute("select count(*) from dogs").fetchone()[0] == 2
|
||||
|
|
@ -233,7 +233,7 @@ def test_query_insert_returning_partial_iteration_still_commits(tmpdir):
|
|||
)
|
||||
)
|
||||
assert row == {"name": "Pancakes"}
|
||||
assert not db.conn.in_transaction
|
||||
assert not db.in_transaction
|
||||
other = sqlite3.connect(path)
|
||||
assert other.execute("select count(*) from dogs").fetchone()[0] == 3
|
||||
other.close()
|
||||
|
|
@ -252,7 +252,7 @@ def test_query_insert_returning_respects_explicit_transaction(fresh_db):
|
|||
)
|
||||
assert rows == [{"name": "Pancakes"}]
|
||||
# Still inside the explicit transaction - not committed
|
||||
assert fresh_db.conn.in_transaction
|
||||
assert fresh_db.in_transaction
|
||||
fresh_db.rollback()
|
||||
assert [row["name"] for row in fresh_db["dogs"].rows] == ["Cleo"]
|
||||
|
||||
|
|
@ -299,5 +299,5 @@ def test_query_preserves_error_from_transaction_destroying_trigger(fresh_db):
|
|||
""")
|
||||
with pytest.raises(sqlite3.IntegrityError, match="trigger says no"):
|
||||
fresh_db.query("insert into t (id, v) values (1, 'bad') returning id")
|
||||
assert not fresh_db.conn.in_transaction
|
||||
assert not fresh_db.in_transaction
|
||||
assert fresh_db.execute("select count(*) from t").fetchone()[0] == 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import sqlite3
|
||||
|
||||
from sqlite_utils.db import ForeignKey, TransactionError, TransformError
|
||||
from sqlite_utils.db import ForeignKey, TransformError
|
||||
from sqlite_utils.utils import OperationalError
|
||||
import pytest
|
||||
|
||||
|
|
@ -111,7 +109,7 @@ def test_transform_sql_table_with_primary_key(
|
|||
|
||||
dogs = fresh_db["dogs"]
|
||||
if use_pragma_foreign_keys:
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
dogs.insert({"id": 1, "name": "Cleo", "age": "5"}, pk="id")
|
||||
sql = dogs.transform_sql(**{**params, **{"tmp_suffix": "suffix"}})
|
||||
assert sql == expected_sql
|
||||
|
|
@ -184,7 +182,7 @@ def test_transform_sql_table_with_no_primary_key(
|
|||
|
||||
dogs = fresh_db["dogs"]
|
||||
if use_pragma_foreign_keys:
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
dogs.insert({"id": 1, "name": "Cleo", "age": "5"})
|
||||
sql = dogs.transform_sql(**{**params, **{"tmp_suffix": "suffix"}})
|
||||
assert sql == expected_sql
|
||||
|
|
@ -353,7 +351,7 @@ def test_transform_foreign_keys_survive_renamed_column(
|
|||
authors_db, use_pragma_foreign_keys
|
||||
):
|
||||
if use_pragma_foreign_keys:
|
||||
authors_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
authors_db.execute("PRAGMA foreign_keys=ON")
|
||||
authors_db["books"].transform(rename={"author_id": "author_id_2"})
|
||||
assert authors_db["books"].foreign_keys == [
|
||||
ForeignKey(
|
||||
|
|
@ -383,7 +381,7 @@ _CAVEAU = {
|
|||
@pytest.mark.parametrize("use_pragma_foreign_keys", [False, True])
|
||||
def test_transform_drop_foreign_keys(fresh_db, use_pragma_foreign_keys):
|
||||
if use_pragma_foreign_keys:
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
# Create table with three foreign keys so we can drop two of them
|
||||
_add_country_city_continent(fresh_db)
|
||||
fresh_db["places"].insert(
|
||||
|
|
@ -415,7 +413,7 @@ def test_transform_drop_foreign_keys(fresh_db, use_pragma_foreign_keys):
|
|||
|
||||
|
||||
def test_transform_verify_foreign_keys(fresh_db):
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db["authors"].insert({"id": 3, "name": "Tina"}, pk="id")
|
||||
fresh_db["books"].insert(
|
||||
{"id": 1, "title": "Book", "author_id": 3}, pk="id", foreign_keys={"author_id"}
|
||||
|
|
@ -432,165 +430,6 @@ def test_transform_verify_foreign_keys(fresh_db):
|
|||
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_pragma_foreign_keys", [False, True])
|
||||
def test_transform_on_delete_cascade_does_not_delete_records(
|
||||
fresh_db, use_pragma_foreign_keys
|
||||
):
|
||||
# Transforming a table drops and recreates it - if another table references
|
||||
# it with ON DELETE CASCADE and PRAGMA foreign_keys is on, that drop must
|
||||
# not cascade and delete the referencing records
|
||||
if use_pragma_foreign_keys:
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
|
||||
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
|
||||
# Transform the table on the other end of the cascading foreign key
|
||||
fresh_db["authors"].transform(rename={"name": "author_name"})
|
||||
assert list(fresh_db["authors"].rows) == [
|
||||
{"id": 1, "author_name": "Ursula K. Le Guin"}
|
||||
]
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
# Transforming the table with the cascading foreign key should not
|
||||
# delete its records either
|
||||
fresh_db["books"].transform(rename={"title": "book_title"})
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "book_title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
if use_pragma_foreign_keys:
|
||||
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("on_delete", ["CASCADE", "SET NULL", "SET DEFAULT", "cascade"])
|
||||
def test_transform_in_transaction_refuses_destructive_on_delete(fresh_db, on_delete):
|
||||
# PRAGMA foreign_keys is a no-op inside a transaction, so transforming a
|
||||
# table referenced by ON DELETE CASCADE / SET NULL / SET DEFAULT foreign
|
||||
# keys inside an open transaction would fire those actions when the old
|
||||
# table is dropped - transform() should refuse instead
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
author_id INTEGER REFERENCES authors(id) ON DELETE {}
|
||||
);
|
||||
""".format(on_delete))
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
|
||||
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
|
||||
previous_schema = fresh_db["authors"].schema
|
||||
with fresh_db.atomic():
|
||||
with pytest.raises(TransactionError) as excinfo:
|
||||
fresh_db["authors"].transform(rename={"name": "author_name"})
|
||||
message = str(excinfo.value)
|
||||
assert "books" in message
|
||||
assert "ON DELETE {}".format(on_delete.upper()) in message
|
||||
# Nothing should have changed
|
||||
assert fresh_db["authors"].schema == previous_schema
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
|
||||
|
||||
def test_transform_in_transaction_refuses_self_referential_cascade(fresh_db):
|
||||
# The copied table carries a foreign key referencing the original table
|
||||
# name, so a self-referential cascade would wipe the copy too
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
fresh_db["categories"].insert_all(
|
||||
[
|
||||
{"id": 1, "name": "Fiction", "parent_id": None},
|
||||
{"id": 2, "name": "Science Fiction", "parent_id": 1},
|
||||
]
|
||||
)
|
||||
with fresh_db.atomic():
|
||||
with pytest.raises(TransactionError) as excinfo:
|
||||
fresh_db["categories"].transform(rename={"name": "title"})
|
||||
assert "categories" in str(excinfo.value)
|
||||
assert fresh_db["categories"].count == 2
|
||||
|
||||
|
||||
def test_transform_in_transaction_allowed_with_no_action_foreign_key(fresh_db):
|
||||
# An inbound foreign key without a destructive ON DELETE action is safe
|
||||
# inside a transaction thanks to PRAGMA defer_foreign_keys
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
author_id INTEGER REFERENCES authors(id)
|
||||
);
|
||||
""")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
|
||||
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
|
||||
with fresh_db.atomic():
|
||||
fresh_db["authors"].transform(rename={"name": "author_name"})
|
||||
assert list(fresh_db["authors"].rows) == [
|
||||
{"id": 1, "author_name": "Ursula K. Le Guin"}
|
||||
]
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
|
||||
|
||||
|
||||
def test_transform_in_transaction_allowed_for_child_table(fresh_db):
|
||||
# The table being transformed only has an outbound foreign key - dropping
|
||||
# it fires no ON DELETE actions, so this is allowed inside a transaction
|
||||
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
|
||||
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
|
||||
with fresh_db.atomic():
|
||||
fresh_db["books"].transform(rename={"title": "book_title"})
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "book_title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_transform_in_transaction_allowed_with_foreign_keys_off(fresh_db):
|
||||
# With PRAGMA foreign_keys off (the default) no cascades can fire, so
|
||||
# transform inside a transaction is safe even with a CASCADE schema
|
||||
fresh_db.executescript("""
|
||||
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
|
||||
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
|
||||
with fresh_db.atomic():
|
||||
fresh_db["authors"].transform(rename={"name": "author_name"})
|
||||
assert list(fresh_db["books"].rows) == [
|
||||
{"id": 1, "title": "The Dispossessed", "author_id": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_transform_add_foreign_keys_from_scratch(fresh_db):
|
||||
_add_country_city_continent(fresh_db)
|
||||
fresh_db["places"].insert(_CAVEAU)
|
||||
|
|
@ -727,63 +566,13 @@ def test_transform_preserves_rowids(fresh_db, table_type):
|
|||
assert previous_rows == next_rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_strict,transform_strict,expected_strict",
|
||||
(
|
||||
(False, None, False),
|
||||
(True, None, True),
|
||||
(False, True, True),
|
||||
(True, False, False),
|
||||
),
|
||||
)
|
||||
def test_transform_strict(fresh_db, initial_strict, transform_strict, expected_strict):
|
||||
if not fresh_db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
dogs = fresh_db.table("dogs", strict=initial_strict)
|
||||
@pytest.mark.parametrize("strict", (False, True))
|
||||
def test_transform_strict(fresh_db, strict):
|
||||
dogs = fresh_db.table("dogs", strict=strict)
|
||||
dogs.insert({"id": 1, "name": "Cleo"})
|
||||
assert dogs.strict is initial_strict
|
||||
dogs.transform(strict=transform_strict)
|
||||
assert dogs.strict is expected_strict
|
||||
|
||||
|
||||
def test_transform_to_strict_with_invalid_data(fresh_db):
|
||||
if not fresh_db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
dogs = fresh_db["dogs"]
|
||||
dogs.create({"id": int})
|
||||
dogs.insert({"id": "not-an-integer"})
|
||||
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
dogs.transform(strict=True)
|
||||
|
||||
assert dogs.strict is False
|
||||
assert list(dogs.rows) == [{"id": "not-an-integer"}]
|
||||
assert fresh_db.table_names() == ["dogs"]
|
||||
|
||||
|
||||
def test_transform_strict_updates_default(fresh_db):
|
||||
if not fresh_db.supports_strict:
|
||||
pytest.skip("SQLite version does not support strict tables")
|
||||
table = fresh_db.table("items", strict=True)
|
||||
table.create({"id": int})
|
||||
|
||||
table.transform(strict=False)
|
||||
assert table.strict is False
|
||||
|
||||
table.create({"id": int}, replace=True)
|
||||
assert table.strict is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method_name", ("transform", "transform_sql"))
|
||||
def test_transform_to_strict_not_supported(fresh_db, method_name):
|
||||
table = fresh_db["items"]
|
||||
table.create({"id": int})
|
||||
fresh_db._supports_strict = False
|
||||
|
||||
with pytest.raises(TransformError, match="SQLite does not support STRICT tables"):
|
||||
getattr(table, method_name)(strict=True)
|
||||
|
||||
assert table.strict is False
|
||||
assert dogs.strict == strict or not fresh_db.supports_strict
|
||||
dogs.transform(not_null={"name"})
|
||||
assert dogs.strict == strict or not fresh_db.supports_strict
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -52,12 +52,17 @@ def test_disable_wal_inside_transaction_raises(db_path_tmpdir):
|
|||
def test_ensure_autocommit_on(db_path_tmpdir):
|
||||
db, path, tmpdir = db_path_tmpdir
|
||||
previous_isolation_level = db.conn.isolation_level
|
||||
assert previous_isolation_level is not None
|
||||
previous_autocommit = getattr(db.conn, "autocommit", None)
|
||||
with db.ensure_autocommit_on():
|
||||
# isolation_level of None means driver-level autocommit mode
|
||||
assert db.conn.isolation_level is None
|
||||
# Driver-level autocommit mode: raw writes on the connection do not
|
||||
# open an implicit transaction, they commit immediately
|
||||
db.conn.execute("create table t1 (id integer)")
|
||||
db.conn.execute("insert into t1 values (1)")
|
||||
assert not db.conn.in_transaction
|
||||
# Restored afterwards
|
||||
assert db.conn.isolation_level == previous_isolation_level
|
||||
assert getattr(db.conn, "autocommit", None) == previous_autocommit
|
||||
assert db.execute("select count(*) from t1").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_enable_wal_noop_inside_transaction_is_allowed(db_path_tmpdir):
|
||||
|
|
@ -83,6 +88,6 @@ def test_ensure_autocommit_on_inside_transaction_raises(db_path_tmpdir):
|
|||
with db.ensure_autocommit_on():
|
||||
pass
|
||||
# The transaction is still open and can still be rolled back
|
||||
assert db.conn.in_transaction
|
||||
assert db.in_transaction
|
||||
db.rollback()
|
||||
assert [r["id"] for r in db["test"].rows] == [1]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue